{
  "name": "PDF Invoices from Mailbox → ZUGFeRD / XRechnung (per department)",
  "nodes": [
    {
      "parameters": {
        "mailbox": "INBOX",
        "postProcessAction": "read",
        "downloadAttachments": true,
        "format": "resolved",
        "dataPropertyAttachmentsPrefixName": "attachment_",
        "options": {
          "customEmailConfig": "[\"UNSEEN\"]",
          "forceReconnect": 60
        }
      },
      "name": "Mailbox: Musterfirma (IMAP)",
      "type": "n8n-nodes-base.emailReadImap",
      "typeVersion": 2,
      "position": [
        -1000,
        300
      ],
      "credentials": {}
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "dep-1",
              "name": "department",
              "value": "musterfirma",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "name": "Department: musterfirma",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -780,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// ── Load Master Data ──────────────────────────────────────────────────\n// Mode: \"Run Once for Each Item\"\n//\n// The only node you maintain in day-to-day operation. It holds two tables:\n//\n//   DEPARTMENTS — per department: mailbox, parser, template, sender details,\n//                 output paths and the address that receives error reports\n//   CUSTOMERS   — per buyer: e-mail address and Leitweg-ID / buyer reference.\n//                 Neither is printed on the incoming PDFs, so both must come\n//                 from here.\n//\n// Which department applies is set by the small Set node behind each IMAP\n// trigger (field `department`).\n\nconst DEPARTMENTS = {\n  musterfirma: {\n    name: 'Musterfirma GmbH',\n    parser: 'generic',                 // see node \"Read Document (rule-based)\"\n    template: 'generic',               // see node \"Select PDF Template\"\n    pageSeparator: null,               // header line printed on every page; only needed if the\n                                       // Extract node ever returns one string instead of pages\n    mailbox: 'invoices@example.com',\n    errorMail: 'office@example.com',   // gets the plain-language rejection mail\n    releaseMail: '',                   // optional: who approves documents in node \"Request Release\" (empty = errorMail)\n    senderMail: 'invoices@example.com',\n    outputPath: '/home/node/e-invoice/out/musterfirma',\n    archivePath: '/home/node/e-invoice/in/musterfirma',\n    logPath: '/home/node/e-invoice/log/musterfirma.jsonl',\n    targetFormat: 'zugferd',           // zugferd | facturx | xrechnung | ubl | cii\n    templateId: '',                    // set = the template stored in your invoice-api.xhub account wins\n    seller: {\n      name: 'Musterfirma GmbH',\n      street: 'Musterstraße 1', postalCode: '12345', city: 'Musterstadt', countryCode: 'DE',\n      vatId: 'DE123456788',\n      email: 'invoices@example.com',\n      // BR-DE-2 requires a seller contact, BR-DE-6 (XRechnung) its phone number\n      contact: { name: 'Erika Muster', phone: '+49 30 1234567', email: 'erika.muster@example.com' },\n      // BR-DE-1 requires payment details — IBAN below is the official test IBAN\n      bankAccount: { iban: 'DE02120300000000202051', bic: 'BYLADEM1001', bankName: 'Deutsche Kreditbank' },\n    },\n    // Anything printed on the letterhead that EN 16931 has no field for.\n    // Only used by the PDF template, never written into the XML.\n    extras: {\n      registerCourt: 'Amtsgericht Musterstadt',\n      registerNumber: 'HRB 12345',\n      managingDirector: 'Max Muster',\n    },\n  },\n};\n\n// ── Customer master data ──────────────────────────────────────────────\n// Key = buyer name as printed in the address block, lower-cased, whitespace\n// collapsed. If your documents carry a customer number, key by that instead.\nconst CUSTOMERS = {\n  'beispiel kunde ag': {\n    name: 'Beispiel Kunde AG',\n    email: 'accounts-payable@example.org',\n    leitwegId: '991-12345-67',        // buyer reference (BT-10); Leitweg-ID for B2G\n    // street/postalCode/city/countryCode/vatId are optional — if set they win over the PDF\n    // peppolId: '0204:991-12345-67', // only when you send via Peppol\n  },\n};\n\n/** buyer name → master data key (lower-case, single spaces) */\nfunction customerKey(name) {\n  return String(name || '').toLowerCase().replace(/\\s+/g, ' ').trim();\n}\n\nconst key = String($input.item.json.department || '').toLowerCase();\nconst department = DEPARTMENTS[key];\n\nif (!department) {\n  throw new Error(\n    `Unknown department \"${key}\". Known keys: ${Object.keys(DEPARTMENTS).join(', ')}. ` +\n      'The Set node behind the IMAP trigger must set one of these.'\n  );\n}\n\nreturn {\n  json: {\n    ...$input.item.json,\n    departmentKey: key,\n    department,\n    customers: CUSTOMERS,\n    customerKeyRule: 'lower-case, whitespace collapsed',\n  },\n  binary: $input.item.binary,\n};\n"
      },
      "name": "Load Master Data",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -560,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ── Split PDF Attachments ─────────────────────────────────────────────\n// Mode: \"Run Once for All Items\"\n// Takes the mails from the IMAP trigger and emits one item per PDF attachment.\n// Mails without a PDF drop out silently.\n//\n// The department's master data (from the previous node) travels with every\n// item so that the following nodes never have to look back.\n\n/** \"Max Muster <max@example.com>\" → \"max@example.com\" */\nfunction mailAddress(value) {\n  if (!value) return null;\n  if (typeof value === 'object') {\n    if (Array.isArray(value.value) && value.value[0]?.address) return value.value[0].address;\n    if (value.address) return value.address;\n    if (value.text) return mailAddress(value.text);\n    return null;\n  }\n  const m = String(value).match(/<([^>]+)>/);\n  return (m ? m[1] : String(value)).trim().toLowerCase() || null;\n}\n\n/** display name, if any */\nfunction mailName(value) {\n  if (!value) return null;\n  if (typeof value === 'object') {\n    if (Array.isArray(value.value) && value.value[0]?.name) return value.value[0].name;\n    if (value.name) return value.name;\n    if (value.text) return mailName(value.text);\n    return null;\n  }\n  const m = String(value).match(/^\\s*\"?([^\"<]+?)\"?\\s*</);\n  return m ? m[1].trim() : null;\n}\n\nconst out = [];\n\n$input.all().forEach((item, mailIndex) => {\n  const mail = item.json || {};\n  // carry only the master-data fields, not the whole raw mail record\n  const base = {\n    departmentKey: mail.departmentKey,\n    department: mail.department,\n    customers: mail.customers,\n  };\n  const binary = item.binary || {};\n\n  const messageId = String(mail.messageId || mail.id || `mail-${mailIndex}`).replace(/[<>]/g, '');\n\n  const mailMeta = {\n    messageId,\n    from: mailAddress(mail.from),\n    fromName: mailName(mail.from),\n    to: mailAddress(mail.to),\n    subject: mail.subject || null,\n    date: mail.date || null,\n  };\n\n  let n = 0;\n  for (const [key, bin] of Object.entries(binary)) {\n    if (!bin) continue;\n    const name = bin.fileName || '';\n    const isPdf = bin.mimeType === 'application/pdf' || /\\.pdf$/i.test(name);\n    if (!isPdf) continue;\n\n    out.push({\n      json: {\n        ...base,\n        docId: `${messageId}#${n}`,\n        mail: mailMeta,\n        sourceFile: name || `attachment-${n}.pdf`,\n        sourceField: key,\n        receivedAt: new Date().toISOString(),\n      },\n      binary: { pdf: bin },\n      pairedItem: { item: mailIndex },\n    });\n    n++;\n  }\n});\n\n// empty array = no PDFs in this mail, the branch ends here\nreturn out;\n"
      },
      "name": "Split PDF Attachments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -340,
        300
      ]
    },
    {
      "parameters": {
        "operation": "write",
        "fileName": "={{ $json.department.archivePath }}/{{ $now.format('yyyy-MM-dd') }}_{{ $json.sourceFile }}",
        "dataPropertyName": "pdf",
        "options": {}
      },
      "name": "Archive Source PDF",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        -120,
        120
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "operation": "pdf",
        "binaryPropertyName": "pdf",
        "options": {
          "joinPages": false,
          "keepSource": "both"
        }
      },
      "name": "Extract from File (PDF)",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        -120,
        400
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// ── Clean Text & Detect Scans ─────────────────────────────────────────\n// Mode: \"Run Once for Each Item\"\n// Input: output of \"Extract from File\" (operation PDF, Join Pages OFF)\n//   { numpages, numrender, info, metadata, text: string[] , version }\n//\n// Cleans the raw text page by page and flags PDFs that have no text layer.\n// A scanned image PDF yields (almost) no characters — the Extract node does\n// NOT error on that, it just returns empty text with a green status.\n\nconst src = $input.item.json;\n\nconst rawPages = Array.isArray(src.text) ? src.text : [String(src.text ?? '')];\n\nconst clean = (s) =>\n  String(s ?? '')\n    .replace(/\\r\\n/g, '\\n')\n    .replace(/-\\n(?=[a-zäöüß])/g, '')   // re-join words hyphenated at line end\n    .replace(/[ \\t]+/g, ' ')            // collapse runs of spaces\n    .replace(/[ \\t]+\\n/g, '\\n')         // trailing spaces before a newline\n    .replace(/\\n{3,}/g, '\\n\\n')         // more than one blank line\n    .trim();\n\nconst pages = rawPages.map(clean);\nconst fullText = pages.join('\\n\\n');\n\n// PDF date \"D:20260714103000+02'00'\" or XMP ISO → ISO-8601\nfunction parsePdfDate(value) {\n  if (!value) return null;\n  const raw = String(value).trim();\n  if (/^\\d{4}-\\d{2}-\\d{2}/.test(raw)) {\n    const d = new Date(raw);\n    return Number.isNaN(d.getTime()) ? null : d.toISOString();\n  }\n  const m = raw.match(/^D?:?(\\d{4})(\\d{2})?(\\d{2})?(\\d{2})?(\\d{2})?(\\d{2})?(?:([+-Z])(\\d{2})'?(\\d{2})'?)?/);\n  if (!m) return null;\n  const [, y, mo = '01', d = '01', h = '00', mi = '00', s = '00', sign, oh, om] = m;\n  const offset = !sign || sign === 'Z' ? 'Z' : `${sign}${oh}:${om}`;\n  const date = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}${offset}`);\n  return Number.isNaN(date.getTime()) ? null : date.toISOString();\n}\n\nconst info = src.info || {};\nconst xmp = src.metadata || {};\nconst pick = (...keys) => {\n  for (const k of keys) {\n    const v = info[k] ?? xmp[k];\n    if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();\n  }\n  return null;\n};\n\nconst pageCount = src.numpages ?? pages.length;\nconst charsPerPage = pageCount ? Math.round(fullText.length / pageCount) : 0;\n// fewer than ~100 characters per page = most likely a scan without a text layer\nconst isLikelyScanned = pageCount > 0 && charsPerPage < 100;\n\nreturn {\n  json: {\n    ...$input.item.json,\n    text: pages,                         // one entry per page, cleaned\n    pdf: {\n      pages: pageCount,\n      creator: pick('Creator', 'xmp:CreatorTool'),\n      producer: pick('Producer', 'pdf:Producer'),\n      createdAt: parsePdfDate(info.CreationDate ?? xmp['xmp:CreateDate']),\n      characters: fullText.length,\n      charsPerPage,\n      isLikelyScanned,\n    },\n  },\n  binary: $input.item.binary,\n};\n"
      },
      "name": "Clean Text & Detect Scans",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        100,
        400
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ── Read Document (rule-based) ────────────────────────────────────────\n// Mode: \"Run Once for All Items\"\n//\n// Rule-based extraction — no language model. Incoming PDFs come out of\n// Word/Excel templates and look the same every time, so a handful of\n// regular expressions read them reliably: same file in, same result out,\n// nothing to pay per document, no external service.\n//\n// One parser per department layout. Which one applies is set in master\n// data (`department.parser`). A PDF may contain several invoices (one per\n// page), so each page is read on its own.\n//\n// RULE: a parser that cannot find a field returns null. It never guesses.\n// Missing fields surface in the next node, by name.\n\nconst MONTHS = { januar: 1, january: 1, februar: 2, february: 2, 'märz': 3, maerz: 3, march: 3,\n  april: 4, mai: 5, may: 5, juni: 6, june: 6, juli: 7, july: 7, august: 8, september: 9,\n  oktober: 10, october: 10, november: 11, dezember: 12, december: 12 };\n\n/** \"1.500,00\" → 1500 · \"1,500.00\" → 1500 · \"-1.991,11\" → -1991.11 · \"12,5\" → 12.5 */\nfunction num(s) {\n  if (s === null || s === undefined) return null;\n  let t = String(s).replace(/\\s/g, '');\n  const m = t.match(/[.,](\\d{1,2})$/);                // decimal separator = last one with 1–2 digits\n  if (m) {\n    const sep = t[t.length - m[1].length - 1];\n    t = t.slice(0, -m[1].length - 1).replace(/[.,]/g, '') + '.' + m[1];\n    if (sep !== '.' && sep !== ',') return null;\n  } else {\n    t = t.replace(/[.,]/g, '');                        // no decimals → all separators are thousands\n  }\n  const n = Number.parseFloat(t);\n  return Number.isFinite(n) ? n : null;\n}\n\n/** \"14.07.2026\" · \"14. Juli 2026\" · \"2026-07-14\" → \"2026-07-14\" */\nfunction isoDate(s) {\n  if (!s) return null;\n  const str = String(s);\n  let m = str.match(/(\\d{4})-(\\d{2})-(\\d{2})/);\n  if (m) return `${m[1]}-${m[2]}-${m[3]}`;\n  m = str.match(/(\\d{1,2})\\.\\s*([A-Za-zÄÖÜäöü]+)\\.?\\s*(\\d{4})/);\n  if (m && MONTHS[m[2].toLowerCase()]) {\n    return `${m[3]}-${String(MONTHS[m[2].toLowerCase()]).padStart(2, '0')}-${m[1].padStart(2, '0')}`;\n  }\n  m = str.match(/(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})/);\n  return m ? `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}` : null;\n}\n\nconst lines = (text) => String(text || '').split('\\n').map((l) => l.trim()).filter(Boolean);\nconst find = (ls, re) => { for (const l of ls) { const m = l.match(re); if (m) return m; } return null; };\n\n// ══════════════════════════════════════════════════════════════════════\n// Parsers, one per department layout. Adding a department = a new function\n// here + an entry in master data. Every parser receives the text of ONE page.\n//\n// `generic` reads the layout of the example PDF shipped with this template\n// (label: value lines, one line item per line, totals block). Copy it and\n// change the regular expressions to match your own Word/Excel template.\n// ══════════════════════════════════════════════════════════════════════\nconst PARSERS = {\n\n  generic(text) {\n    const ls = lines(text);\n\n    // document number decides whether this page is a document at all\n    // negative lookaheads keep \"Invoice date:\", \"Invoice to:\", \"Gutschriftbetrag\" from being read as a number\n    const credit = find(ls, /^(?:Credit\\s*note|Gutschrift(?:s)?)(?!s?\\s*(?:dat|betrag|amount|to\\b))(?:\\s*(?:No\\.?|Nr\\.?|number))?\\s*:?\\s*(\\S.*)$/i);\n    const invoice = find(ls, /^(?:Invoice|Rechnung)(?!s?\\s*(?:dat|betrag|amount|to\\b|an\\b|semp))(?:\\s*(?:No\\.?|Nr\\.?|number|snummer))?\\s*:?\\s*(\\S.*)$/i);\n    const numberMatch = credit || invoice;\n    if (!numberMatch) return null;                       // no document on this page (attachment, terms, …)\n    const invoiceNumber = numberMatch[1].trim();\n\n    const issue = find(ls, /^(?:Invoice\\s*date|Rechnungsdatum|Datum|Date):?\\s*(.+)$/i);\n    const due = find(ls, /^(?:Due\\s*date|Fällig(?:keit)?(?:\\s*bis)?|Zahlbar\\s*bis|Payable\\s*by):?\\s*(.+)$/i);\n    const order = find(ls, /^(?:Order\\s*(?:No\\.?|number)|Bestell(?:-?nummer|ung)|Auftrag(?:s-?nummer)?):?\\s*(\\S+)/i);\n    const vatId = find(ls, /(?:VAT\\s*ID|USt-?IdNr\\.?|UID):?\\s*([A-Z]{2}[0-9A-Z]{2,})/i);\n\n    // totals block — amounts like \"1.500,00 EUR\", \"1,500.00 EUR\", \"EUR 1.500,00\"\n    const amount = '(-?[\\\\d.,]+)\\\\s*(?:EUR|€)?';\n    const net = find(ls, new RegExp(`^(?:Net(?:\\\\s*total|\\\\s*amount)?|Netto(?:betrag|summe)?|Subtotal|Zwischensumme):?\\\\s*(?:EUR\\\\s*)?${amount}$`, 'i'));\n    const vat = find(ls, new RegExp(`^(?:VAT|USt\\\\.?|MwSt\\\\.?|Umsatzsteuer)\\\\s*\\\\+?\\\\s*([\\\\d.,]+)\\\\s*%:?\\\\s*(?:EUR\\\\s*)?${amount}$`, 'i'));\n    const total = find(ls, new RegExp(`^(?:Total(?:\\\\s*amount)?|Gesamt(?:betrag|summe)?|Rechnungsbetrag|Gutschriftbetrag|Brutto(?:betrag)?):?\\\\s*(?:EUR\\\\s*)?${amount}$`, 'i'));\n\n    // line items: \"<description>  <amount> EUR\" — everything that is not a totals line\n    const isTotalsLine = (l) => /^(Net|Netto|Subtotal|Zwischensumme|VAT|USt|MwSt|Umsatzsteuer|Total|Gesamt|Rechnungsbetrag|Gutschriftbetrag|Brutto)/i.test(l);\n    const items = [];\n    for (const l of ls) {\n      const m = l.match(/^(.+?)\\s{2,}(-?[\\d.,]+)\\s*(?:EUR|€)$/) || l.match(/^(.+?)\\s+(-?\\d[\\d.,]*[.,]\\d{2})\\s*(?:EUR|€)$/);\n      if (!m || isTotalsLine(m[1])) continue;\n      // optional \"<qty> x <unit price>\" in front of the amount, e.g. \"Consulting 10 x 150,00   1.500,00 EUR\"\n      const q = m[1].match(/^(.*?)\\s+(\\d+(?:[.,]\\d+)?)\\s*[x×]\\s*(-?[\\d.,]+)$/);\n      items.push({\n        position: items.length + 1,\n        description: (q ? q[1] : m[1]).trim().replace(/[,;:]$/, ''),\n        quantity: q ? num(q[2]) : 1,\n        unit: 'C62',\n        unitPrice: q ? num(q[3]) : null,\n        netAmount: num(m[2]),\n        taxRate: vat ? num(vat[1]) : null,\n      });\n    }\n\n    // buyer address: lines after \"Bill to:\" / \"Rechnungsempfänger:\" up to the first\n    // label line; fallback: between the one-line return address (\"A • B • C\") and the\n    // first date-looking line — the classic German letter layout\n    const isLabel = (l) => /^(Invoice|Rechnung|Credit|Gutschrift|Date|Datum|Due|Fällig|Order|Bestell|Auftrag|Customer\\s*(?:no|number)|Kundennr|Kundennummer|Dear|Sehr geehrte)/i.test(l) || (Boolean(isoDate(l)) && /^\\S+$/.test(l));\n    let buyerLines = [];\n    const iBill = ls.findIndex((l) => /^(Bill\\s*to|Invoice\\s*to|Rechnungsempfänger|Rechnungsanschrift|Rechnung\\s*an|An):?$/i.test(l));\n    if (iBill >= 0) {\n      for (const l of ls.slice(iBill + 1, iBill + 7)) { if (isLabel(l)) break; buyerLines.push(l); }\n    } else {\n      const iSender = ls.findIndex((l) => /\\s[•·|]\\s/.test(l));\n      const iDate = ls.findIndex((l, i) => i > iSender && isoDate(l));\n      if (iSender >= 0 && iDate > iSender) buyerLines = ls.slice(iSender + 1, iDate).filter((l) => !isLabel(l)).slice(0, 6);\n    }\n\n    // free text between the salutation and the first line item — reappears on the PDF as notes\n    const iHello = ls.findIndex((l) => /^(Dear|Sehr geehrte)/.test(l));\n    const intro = iHello >= 0\n      ? ls.slice(iHello + 1, iHello + 3).filter((l) => !/(EUR|€)$/.test(l) && !isLabel(l)).join('\\n') || null\n      : null;\n\n    return {\n      documentType: credit ? 'credit_note' : 'invoice',\n      invoiceNumber,\n      issueDate: issue ? isoDate(issue[1]) : null,\n      dueDate: due ? isoDate(due[1]) : null,\n      currency: 'EUR',\n      orderNumber: order ? order[1] : null,\n      vatIdOnDocument: vatId ? vatId[1].toUpperCase() : null,\n      intro,\n      items,\n      taxRate: vat ? num(vat[1]) : null,\n      subtotal: net ? num(net[1]) : null,\n      totalTax: vat ? num(vat[2]) : null,\n      total: total ? num(total[1]) : null,\n      buyerLines,\n    };\n  },\n};\n\n// ══════════════════════════════════════════════════════════════════════\n\n/**\n * Split text into pages. With \"Join Pages\" off, the Extract node delivers one\n * string per page. If it ever delivers a single string, split at the header\n * line that is printed on every page (`pageSeparator` in master data).\n */\nfunction pagesOf(text, separator) {\n  if (Array.isArray(text)) return text;\n  const s = String(text || '');\n  if (!s) return [];\n  if (!separator) return [s];\n  const parts = s.split(new RegExp(`\\\\n(?=${separator})`)).filter((t) => t.trim());\n  return parts.length ? parts : [s];\n}\n\nconst out = [];\n\nfor (const [i, item] of $input.all().entries()) {\n  const k = item.json;\n  const parserName = k.department?.parser;\n  const parser = PARSERS[parserName];\n\n  if (!parser) {\n    out.push({ json: { ...k, documents: [], readError:\n      `No parser \"${parserName}\" defined. Available: ${Object.keys(PARSERS).join(', ')}.` },\n      pairedItem: { item: i } });\n    continue;\n  }\n\n  if (k.pdf?.isLikelyScanned) {\n    out.push({ json: { ...k, documents: [], readError:\n      `\"${k.sourceFile}\" has no text layer (${k.pdf.charsPerPage} characters per page) — most likely a scan. ` +\n      'This workflow has no OCR; add one in front of \"Read Document\" or ask the department for the original PDF.' },\n      pairedItem: { item: i } });\n    continue;\n  }\n\n  const pages = pagesOf(k.text, k.department?.pageSeparator);\n  const documents = [];\n  pages.forEach((page, n) => {\n    try {\n      const d = parser(page);\n      if (d) documents.push({ ...d, page: n + 1 });\n    } catch (e) {\n      documents.push({ page: n + 1, readError: e.message });\n    }\n  });\n\n  out.push({\n    json: { ...k, text: undefined, documents, readWith: parserName, pagesRead: pages.length },\n    pairedItem: { item: i },\n  });\n}\n\nreturn out;\n"
      },
      "name": "Read Document (rule-based)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        320,
        400
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ── Build EN 16931 Invoice ────────────────────────────────────────────\n// Mode: \"Run Once for All Items\"\n//\n// Assembles the invoice object that invoice-api.xhub expects for Validate\n// and Generate from three sources:\n//\n//   1. Document   — what is printed on the PDF (node \"Read Document\")\n//   2. Department — seller, bank account, contact person (master data)\n//   3. Customer   — e-mail address and Leitweg-ID (customer table)\n//\n// Source 3 is why the customer table exists: neither the buyer's e-mail nor\n// the Leitweg-ID is printed on the incoming PDFs. Without them there is no\n// recipient and no valid buyer reference (BT-10 / BR-DE-15).\n//\n// The numbers are recomputed. What does not add up is not sent.\n\nconst round = (n) => (n === null || n === undefined ? null : Math.round(n * 100) / 100);\nconst text = (v) => {\n  const s = v === null || v === undefined ? '' : String(v).trim();\n  return s === '' ? null : s;\n};\n\nconst COUNTRIES = { deutschland: 'DE', germany: 'DE', österreich: 'AT', oesterreich: 'AT',\n  austria: 'AT', schweiz: 'CH', switzerland: 'CH', frankreich: 'FR', france: 'FR',\n  niederlande: 'NL', netherlands: 'NL', belgien: 'BE', belgium: 'BE' };\n\n/** address lines from the address block → structured address */\nfunction addressFromLines(ls) {\n  const z = (ls || []).map((l) => String(l).trim()).filter(Boolean);\n  if (!z.length) return {};\n\n  // last line may be the country (\"Deutschland\", \"Germany\", \"DE\")\n  let country = null;\n  const last = z[z.length - 1];\n  if (COUNTRIES[last.toLowerCase()]) { country = COUNTRIES[last.toLowerCase()]; z.pop(); }\n  else if (/^[A-Z]{2}$/.test(last)) { country = last; z.pop(); }\n\n  // postal code + city line (\"12345 Musterstadt\", \"D-12345 Musterstadt\")\n  const iZip = z.findIndex((l) => /^(D-)?\\d{4,5}\\s+\\S/.test(l));\n  const m = iZip >= 0 ? z[iZip].match(/^(?:D-)?(\\d{4,5})\\s+(.+)$/) : null;\n\n  return {\n    name: z[0] || null,\n    street: iZip > 1 ? z.slice(1, iZip).join(', ') : null,\n    postalCode: m ? m[1] : null,\n    city: m ? m[2] : null,\n    countryCode: country || 'DE',\n  };\n}\n\n/** tax category per EN 16931: S = standard rate, Z = zero rate */\nconst taxCategory = (rate) => (Number(rate) > 0 ? 'S' : 'Z');\n\nconst customerKey = (name) => String(name || '').toLowerCase().replace(/\\s+/g, ' ').trim();\n\nconst out = [];\n\nfor (const [itemIndex, item] of $input.all().entries()) {\n  const k = item.json;\n  const dep = k.department || {};\n  const customers = k.customers || {};\n  const documents = (k.documents || []).filter((d) => d && !d.readError);\n\n  if (!documents.length) {\n    out.push({\n      json: {\n        ...k, docIndex: 1, docCount: 0, invoiceData: null,\n        check: { ok: false, messages: [\n          k.readError || `No document recognised in \"${k.sourceFile}\". ` +\n          `Read with parser \"${k.readWith}\", ${k.pagesRead} page(s). ` +\n          'Either the department changed its Word template or the PDF is a scan.',\n        ]},\n      },\n      pairedItem: { item: itemIndex },\n    });\n    continue;\n  }\n\n  documents.forEach((doc, docIndex) => {\n    const messages = [];\n\n    const isCreditNote = doc.documentType === 'credit_note'\n      || (doc.total !== null && doc.total < 0);\n\n    // EN 16931: credit notes carry POSITIVE amounts, the document type carries the sign.\n    const sign = isCreditNote ? -1 : 1;\n    const amount = (v) => (v === null || v === undefined ? null : round(v * sign));\n\n    // ---------- seller from master data ----------\n    const s = dep.seller || {};\n    const seller = {\n      name: text(s.name), street: text(s.street), postalCode: text(s.postalCode),\n      city: text(s.city), countryCode: (text(s.countryCode) || 'DE').toUpperCase(),\n      ...(text(s.vatId) ? { vatId: text(s.vatId).replace(/\\s/g, '').toUpperCase() } : {}),\n      ...(text(s.taxId) ? { taxId: text(s.taxId) } : {}),\n      ...(text(s.email) ? { email: text(s.email).toLowerCase() } : {}),\n      ...(s.contact ? { contact: {\n        ...(text(s.contact.name) ? { name: text(s.contact.name) } : {}),\n        ...(text(s.contact.phone) ? { phone: text(s.contact.phone) } : {}),\n        ...(text(s.contact.email) ? { email: text(s.contact.email) } : {}),\n      }} : {}),\n      ...(s.bankAccount?.iban ? { bankAccount: {\n        iban: String(s.bankAccount.iban).replace(/\\s/g, '').toUpperCase(),\n        ...(text(s.bankAccount.bic) ? { bic: text(s.bankAccount.bic).toUpperCase() } : {}),\n        ...(text(s.bankAccount.bankName) ? { bankName: text(s.bankAccount.bankName) } : {}),\n      }} : {}),\n    };\n\n    // BR-DE-1 (payment details) and BR-DE-2 (contact) are master-data questions,\n    // not document questions — so they are checked here, not while reading.\n    if (!seller.vatId) messages.push(\n      `Master data of department \"${k.departmentKey}\": VAT ID missing.`);\n    if (!seller.bankAccount) messages.push(\n      `Master data of department \"${k.departmentKey}\": bank account missing — BR-DE-1 requires payment details (BG-16).`);\n    if (!seller.contact) messages.push(\n      `Master data of department \"${k.departmentKey}\": contact person missing — BR-DE-2.`);\n\n    // A different VAT ID on the document than in master data means the mail went\n    // to the wrong mailbox. That is not a rounding error.\n    if (doc.vatIdOnDocument && seller.vatId && doc.vatIdOnDocument !== seller.vatId) {\n      messages.push(\n        `The document carries VAT ID ${doc.vatIdOnDocument}, department \"${k.departmentKey}\" ` +\n        `is registered with ${seller.vatId}. The mail probably went to the wrong mailbox.`);\n    }\n\n    // ---------- buyer: address from the document, the rest from the customer table ----------\n    const fromDoc = addressFromLines(doc.buyerLines);\n    const key = customerKey(fromDoc.name);\n    const customer = customers[key] || null;\n\n    if (!customer) {\n      messages.push(\n        `\"${fromDoc.name || '(no name in the address block)'}\" is not in the customer table. ` +\n        'Without an entry there is no e-mail address and no Leitweg-ID. ' +\n        'Add it in node \"Load Master Data\" under CUSTOMERS.');\n    }\n\n    const buyer = {\n      name: text(customer?.name) || fromDoc.name,\n      street: text(customer?.street) || fromDoc.street,\n      postalCode: text(customer?.postalCode) || fromDoc.postalCode,\n      city: text(customer?.city) || fromDoc.city,\n      countryCode: (text(customer?.countryCode) || fromDoc.countryCode || 'DE').toUpperCase(),\n      ...(text(customer?.vatId) ? { vatId: text(customer.vatId).replace(/\\s/g, '').toUpperCase() } : {}),\n      ...(text(customer?.email) ? { email: text(customer.email).toLowerCase() } : {}),\n    };\n\n    if (!buyer.name) messages.push('The address block has no recipient name.');\n    if (!buyer.street || !buyer.postalCode || !buyer.city)\n      messages.push(`Recipient address incomplete: ${JSON.stringify(doc.buyerLines || [])}`);\n\n    // Buyer reference (BT-10): Leitweg-ID from the customer table, else the reference\n    // printed on the document. For Germany the API requires it in BOTH ZUGFeRD and XRechnung.\n    const buyerReference = text(customer?.leitwegId) || text(doc.buyerReference);\n    if (!buyerReference) messages.push(\n      'Leitweg-ID / buyer reference missing (BT-10, BR-DE-15) — add it to the customer table.');\n\n    // ---------- line items ----------\n    const items = (doc.items || []).map((p, i) => {\n      // BR-27: unit price must not be negative — for credit notes the document type\n      // carries the sign, quantity and price stay positive.\n      const qty = Math.abs(p.quantity ?? 1) || 1;\n      const rate = p.taxRate ?? doc.taxRate ?? 0;\n      const net = amount(p.netAmount) ?? 0;\n      const tax = round((net * rate) / 100);\n      return {\n        position: p.position ?? i + 1,\n        description: text(p.description) || `Item ${i + 1}`,\n        quantity: qty,\n        unit: text(p.unit) || 'C62',\n        unitPrice: Math.abs(round(net / qty)),\n        netAmount: net,\n        taxRate: rate,\n        taxCategoryCode: taxCategory(rate),\n        taxAmount: tax,\n        grossAmount: round(net + tax),\n      };\n    });\n\n    // ---------- totals ----------\n    const itemsNet = round(items.reduce((a, p) => a + p.netAmount, 0));\n    let subtotal = amount(doc.subtotal ?? null);\n    let taxTotal = amount(doc.totalTax ?? null);\n    let total = amount(doc.total ?? null);\n\n    if (subtotal === null) subtotal = itemsNet;\n    if (taxTotal === null) taxTotal = round(items.reduce((a, p) => a + p.taxAmount, 0));\n    if (total === null) total = round(subtotal + taxTotal);\n\n    const perRate = new Map();\n    for (const p of items) {\n      const e = perRate.get(p.taxRate)\n        || { taxRate: p.taxRate, taxCategoryCode: taxCategory(p.taxRate), netAmount: 0, taxAmount: 0 };\n      e.netAmount = round(e.netAmount + p.netAmount);\n      e.taxAmount = round(e.taxAmount + p.taxAmount);\n      perRate.set(p.taxRate, e);\n    }\n    let taxSummary = [...perRate.values()].sort((a, b) => b.taxRate - a.taxRate);\n    if (!taxSummary.length) {\n      const rate = doc.taxRate ?? 19;\n      taxSummary = [{ taxRate: rate, taxCategoryCode: taxCategory(rate),\n                      netAmount: subtotal, taxAmount: taxTotal }];\n    }\n\n    // ---------- recompute ----------\n    const TOLERANCE = 0.02;\n    if (items.length && Math.abs(itemsNet - subtotal) > TOLERANCE) {\n      messages.push(\n        `Line items add up to ${itemsNet.toFixed(2)} net, the document states ${subtotal.toFixed(2)}.`);\n    }\n    if (Math.abs(round(subtotal + taxTotal) - total) > TOLERANCE) {\n      messages.push(\n        `Net ${subtotal.toFixed(2)} + VAT ${taxTotal.toFixed(2)} = ` +\n        `${(subtotal + taxTotal).toFixed(2)}, the document states ${total.toFixed(2)}.`);\n    }\n    const expectedTax = round((subtotal * (taxSummary[0]?.taxRate ?? 0)) / 100);\n    if (taxSummary.length === 1 && Math.abs(expectedTax - taxTotal) > TOLERANCE) {\n      messages.push(\n        `${taxSummary[0]?.taxRate}% of ${subtotal.toFixed(2)} = ` +\n        `${expectedTax.toFixed(2)}, the document states ${taxTotal.toFixed(2)}.`);\n    }\n\n    // ---------- mandatory fields ----------\n    if (!doc.invoiceNumber) messages.push('Document number missing.');\n    if (!doc.issueDate) messages.push('Document date missing or unreadable.');\n\n    const issueDate = doc.issueDate;\n    const dueDate = doc.dueDate || issueDate;   // dueDate is mandatory for the API\n    let dueDays = 0;\n    if (dueDate && issueDate) {\n      const days = Math.round((new Date(dueDate).getTime() - new Date(issueDate).getTime()) / 86400000);\n      if (days >= 0 && days <= 365) dueDays = days;\n    }\n\n    // Payment terms text. A paymentTerms element WITHOUT text violates PEPPOL-EN16931-R008.\n    const paymentText = isCreditNote\n      ? 'Please quote the credit note number when offsetting.'\n      : (doc.dueDate ? `Payable by ${dueDate} without deduction.` : 'Payable immediately without deduction.');\n\n    const invoiceData = {\n      type: isCreditNote ? 'credit_note' : 'invoice',\n      invoiceNumber: doc.invoiceNumber,\n      issueDate,\n      dueDate,\n      currency: (text(doc.currency) || 'EUR').toUpperCase(),\n      seller,\n      buyer,\n      countrySpecific: {\n        countryCode: (text(dep.seller?.countryCode) || 'DE').toUpperCase(),\n        ...(buyerReference ? { buyerReference } : {}),\n      },\n      items,\n      taxSummary,\n      subtotal,\n      total,\n      ...(doc.orderNumber ? { orderNumber: doc.orderNumber } : {}),\n      ...(doc.intro ? { notes: doc.intro } : {}),\n      // BR-DE-1: bankAccount alone is not enough, paymentMethods[] is required as well\n      ...(seller.bankAccount ? { paymentMethods: [\n        { type: 'bank_transfer', details: 'SEPA credit transfer to the account stated' },\n      ]} : {}),\n      paymentTerms: { dueDays, description: paymentText },\n    };\n\n    out.push({\n      json: {\n        docId: documents.length > 1 ? `${k.docId}/${docIndex + 1}` : k.docId,\n        departmentKey: k.departmentKey,\n        department: dep,\n        mail: k.mail,\n        sourceFile: k.sourceFile,\n        receivedAt: k.receivedAt,\n        docIndex: docIndex + 1,\n        docCount: documents.length,\n        page: doc.page ?? null,\n        isCreditNote,\n        customerFound: Boolean(customer),\n        recipient: text(customer?.email),\n        invoiceData,\n        check: { ok: messages.length === 0, messages },\n      },\n      pairedItem: { item: itemIndex },\n    });\n  });\n}\n\nreturn out;\n"
      },
      "name": "Build EN 16931 Invoice",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        540,
        400
      ]
    },
    {
      "parameters": {
        "operation": "validate",
        "countryCode": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.department.seller.countryCode }}"
        },
        "invoiceData": "={{ JSON.stringify($json.invoiceData) }}",
        "options": {
          "failOnErrors": false,
          "failOnWarnings": false
        }
      },
      "name": "invoice-api.xhub: Validate",
      "type": "n8n-nodes-invoice-api-xhub.invoiceXhub",
      "typeVersion": 1,
      "position": [
        760,
        400
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "credentials": {}
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "own-check",
              "leftValue": "={{ $('Build EN 16931 Invoice').item.json.check.ok }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "api-valid",
              "leftValue": "={{ $json.valid }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "api-formats",
              "leftValue": "={{ ($json.results ?? []).filter(r => String(r.format).startsWith(String($('Build EN 16931 Invoice').item.json.department.targetFormat).toLowerCase())).every(r => r.valid !== false) }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "name": "Approved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        980,
        400
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// ── Select PDF Template ───────────────────────────────────────────────\n// Mode: \"Run Once for Each Item\"\n//\n// Every department can have its own visual layout. Which one applies is set\n// in master data (`department.template`). The template is built PER\n// DOCUMENT, so anything the template language cannot do — long-form dates,\n// country names, letterhead extras — is rendered here as finished text.\n// The XML still carries the normalised values.\n//\n// If `department.templateId` is set, the template stored in your\n// invoice-api.xhub account wins and this node only passes the ID on.\n//\n// Units: this template declares `lengthUnit: 'pt'`, so every spatial value\n// (margins, widths, heights, gaps) is in points. Do not omit `lengthUnit` —\n// a template without it is read in legacy mode, where page margins and\n// spacer heights are millimetres while everything else is points.\n// `locale` is set on every summary row: that is where number formatting is\n// read (de-DE → 18.391,71 instead of 18,391.71).\n\n// The Validate node replaces the item JSON with its own response, so the\n// assembled invoice is fetched from the node that built it (paired item).\nconst built = $('Build EN 16931 Invoice').item.json;\nconst validation = $input.item.json;\nconst input = { ...built, validation: { valid: validation.valid, warnings: validation.warnings ?? [], results: validation.results ?? [] } };\nconst dep = input.department || {};\nconst d = input.invoiceData || {};\nconst kind = String(dep.template || 'generic').toLowerCase();\n\nif (dep.templateId) {\n  return { json: { ...input, templateId: dep.templateId, formatOptions: {}, templateSource: 'account' },\n           binary: $input.item.binary };\n}\n\nconst txt = (content, k = {}) => ({ type: 'text', data: { content, ...k } });\nconst COUNTRY = { DE: 'Deutschland', AT: 'Österreich', CH: 'Schweiz', NL: 'Niederlande',\n                  FR: 'Frankreich', BE: 'Belgien' };\nconst countryName = (code) => COUNTRY[String(code || '').toUpperCase()] || String(code || '');\nconst shortDate = (iso) => { const m = String(iso || '').match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);\n  return m ? `${m[3]}.${m[2]}.${m[1]}` : String(iso || ''); };\n\nconst LOCALE = 'de-DE';\nconst money = { format: 'currency', decimalPlaces: 2, locale: LOCALE };\n\nfunction templateGeneric(x, d, isCreditNote) {\n  const F = { small: 7.5, text: 10, title: 14 };\n  const title = isCreditNote ? 'Gutschrift' : 'Rechnung';\n\n  return {\n    version: '1.0.0',\n    name: 'generic-letter',\n    lengthUnit: 'pt',\n    page: { size: 'A4', orientation: 'portrait',\n            margins: { top: 57, right: 45, bottom: 57, left: 60 } },\n    footer: { height: 60, blocks: [\n      txt(`{{seller.name}} · {{seller.street}} · {{seller.postalCode}} {{seller.city}} · USt-IdNr. {{seller.vatId}}`,\n        { fontSize: F.small, alignment: 'center' }),\n      txt(`${x.registerCourt ? `${x.registerCourt} ${x.registerNumber} · ` : ''}` +\n          `${x.managingDirector ? `Geschäftsführung: ${x.managingDirector} · ` : ''}` +\n          `IBAN {{seller.bankAccount.iban}} · BIC {{seller.bankAccount.bic}}`,\n        { fontSize: F.small, alignment: 'center' }),\n    ]},\n    body: { blocks: [\n      txt('{{seller.name}} • {{seller.street}} • {{seller.postalCode}} {{seller.city}}',\n        { fontSize: F.small, margin: [0, 40, 0, 12] }),\n      { type: 'columns', data: { columnGap: 20, columns: [\n        { width: '*', blocks: [\n          txt('{{buyer.name}}', { fontSize: F.text }),\n          txt('{{buyer.street}}', { fontSize: F.text }),\n          txt('{{buyer.postalCode}} {{buyer.city}}', { fontSize: F.text }),\n          txt(countryName(d.buyer && d.buyer.countryCode), { fontSize: F.text, margin: [0, 0, 0, 30] }),\n        ]},\n        { width: 200, blocks: [{ type: 'keyvalue', data: {\n          layout: 'horizontal', fontSize: F.text, labelBold: true, widths: [100, 100],\n          items: [\n            { label: `${title}snummer`, value: '{{invoiceNumber}}' },\n            { label: 'Datum', value: shortDate(d.issueDate) },\n            ...(isCreditNote ? [] : [{ label: 'Fällig am', value: shortDate(d.dueDate) }]),\n            ...(d.orderNumber ? [{ label: 'Ihre Bestellung', value: '{{orderNumber}}' }] : []),\n            ...(d.countrySpecific?.buyerReference ? [{ label: 'Leitweg-ID', value: '{{countrySpecific.buyerReference}}' }] : []),\n          ],\n        }}]},\n      ]}},\n      txt(`${title} {{invoiceNumber}}`, { fontSize: F.title, bold: true, margin: [0, 10, 0, 14] }),\n      txt('{{notes?}}', { fontSize: F.text, margin: [0, 0, 0, 12] }),\n      { type: 'table', data: {\n        dataSource: '{{items}}', layout: 'lightHorizontalLines', showHeader: true,\n        fontSize: F.text, locale: LOCALE,\n        cellPadding: { top: 4, bottom: 4, left: 0, right: 4 },\n        columns: [\n          { field: 'position', header: 'Pos.', width: 30 },\n          { field: 'description', header: 'Bezeichnung', width: '*' },\n          { field: 'quantity', header: 'Menge', width: 45, align: 'right', format: 'number', decimalPlaces: 0, locale: LOCALE },\n          { field: 'unitPrice', header: 'Einzelpreis', width: 75, align: 'right', ...money },\n          { field: 'netAmount', header: 'Netto', width: 80, align: 'right', ...money },\n        ],\n      }},\n      { type: 'summary', data: {\n        widths: [160, 100], fontSize: F.text,\n        headerRows: [],                                              // required by the schema, even when empty\n        dynamicRows: { dataSource: '{{taxSummary}}', labelTemplate: 'zzgl. {{taxRate}} % USt.',\n                       valueField: 'taxAmount', valueFormat: 'currency', locale: LOCALE },\n        footerRows: [\n          { label: 'Nettobetrag', value: '{{subtotal}}', valueFormat: 'currency', locale: LOCALE },\n          { label: `${title}sbetrag`, value: '{{total}}', bold: true, valueFormat: 'currency', locale: LOCALE },\n        ],\n        separatorBeforeFooter: true,\n      }},\n      txt('{{paymentTerms.description}}', { fontSize: F.text, margin: [0, 20, 0, 0] }),\n    ]},\n  };\n}\n\nconst TEMPLATES = { generic: templateGeneric };\nconst build = TEMPLATES[kind];\nif (!build) {\n  throw new Error(`No template \"${kind}\" defined. Available: ${Object.keys(TEMPLATES).join(', ')}.`);\n}\n\nconst template = build(dep.extras || {}, d, Boolean(input.isCreditNote));\n\nreturn {\n  json: { ...input, templateId: '', templateName: template.name, templateSource: 'workflow',\n          formatOptions: { template } },\n  binary: $input.item.binary,\n};\n"
      },
      "name": "Select PDF Template",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1640,
        240
      ]
    },
    {
      "parameters": {
        "operation": "generate",
        "countryCode": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.department.seller.countryCode }}"
        },
        "format": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.department.targetFormat }}"
        },
        "invoiceData": "={{ JSON.stringify($json.invoiceData) }}",
        "options": {
          "outputBinary": true,
          "binaryPropertyName": "data",
          "includeWarnings": true,
          "templateId": "={{ $json.templateId }}",
          "formatOptions": "={{ JSON.stringify($json.formatOptions) }}"
        }
      },
      "name": "invoice-api.xhub: Generate",
      "type": "n8n-nodes-invoice-api-xhub.invoiceXhub",
      "typeVersion": 1,
      "position": [
        1860,
        240
      ],
      "onError": "continueErrorOutput",
      "credentials": {}
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "gen-ok",
              "leftValue": "={{ $json.success }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "name": "Generated?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2080,
        240
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// ── Prepare Result ────────────────────────────────────────────────────\n// Mode: \"Run Once for Each Item\"\n// Decides file name, output path, recipient and cover mail. The generated\n// document stays attached as binary \"data\".\n\nconst gen = $input.item.json;\nconst doc = $('Build EN 16931 Invoice').item.json;\nconst dep = doc.department || {};\nconst d = doc.invoiceData;\n\n/** strip everything that causes trouble in file names */\nconst safe = (s) =>\n  String(s ?? '')\n    .normalize('NFKD').replace(/[̀-ͯ]/g, '')\n    .replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')\n    .slice(0, 60) || 'unnamed';\n\nconst EXT = { 'application/pdf': 'pdf', 'application/xml': 'xml', 'text/xml': 'xml', 'application/json': 'json' };\nconst ext = EXT[gen.mimeType] || (String(gen.format).includes('xrechnung') ? 'xml' : 'pdf');\n\nconst kind = d.type === 'credit_note' ? 'Credit note' : 'Invoice';\n\n// Short content fingerprint in the file name. Two reasons:\n// - the same document received twice → identical name → harmless overwrite\n// - same number, different content     → different name → nothing gets lost\n//\n// Do NOT use `hash` from the API response for this. It is the SHA-256 of the\n// generated bytes, and a PDF contains a creation timestamp — so it changes on\n// every call, even for identical input, and would create a new file each run.\n// This fingerprint is computed over the invoice data with stable key order.\nfunction fingerprintOf(obj) {\n  const canonical = JSON.stringify(obj, (_, v) =>\n    v && typeof v === 'object' && !Array.isArray(v)\n      ? Object.keys(v).sort().reduce((o, k) => ((o[k] = v[k]), o), {})\n      : v);\n  let h = 0x811c9dc5;                       // FNV-1a, 32 bit\n  for (let i = 0; i < canonical.length; i++) {\n    h ^= canonical.charCodeAt(i);\n    h = Math.imul(h, 0x01000193) >>> 0;\n  }\n  return h.toString(16).padStart(8, '0');\n}\n\nconst fingerprint = fingerprintOf(d);\nconst fileName = `${d.issueDate}_${safe(d.invoiceNumber)}_${safe(gen.format)}_${fingerprint}.${ext}`;\nconst targetPath = `${String(dep.outputPath || '.').replace(/\\/+$/, '')}/${fileName}`;\n\n// Recipient: address from the customer table. If it is missing, the document\n// already stopped in the check — this branch is never reached then.\nconst recipient = String(doc.recipient || d.buyer?.email || '').trim().toLowerCase();\nif (!recipient) {\n  throw new Error(\n    `No recipient for ${d.invoiceNumber}: \"${d.buyer?.name}\" has no e-mail address in the customer table.`);\n}\n\nconst amount = `${new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2 }).format(Number(d.total))} ${d.currency}`;\nconst FORMAT_LABEL = { zugferd: 'ZUGFeRD', facturx: 'Factur-X', xrechnung: 'XRechnung',\n                       ubl: 'UBL', 'peppol-ubl': 'Peppol UBL', cii: 'CII' };\nconst formatLabel = FORMAT_LABEL[String(gen.format).toLowerCase()] || String(gen.format);\n\n// Cover mail — adjust wording and language to your buyers.\nconst mailText = [\n  'Dear Sir or Madam,',\n  '',\n  `please find attached ${kind.toLowerCase()} ${d.invoiceNumber} dated ${d.issueDate} for ${amount} as ${formatLabel}.`,\n  d.type === 'credit_note' ? null : `Due by ${d.dueDate}.`,\n  '',\n  'The attached document conforms to EN 16931 and can be imported directly into your accounting system.',\n  '',\n  'Kind regards',\n  d.seller.name,\n].filter((l) => l !== null).join('\\n');\n\nreturn {\n  json: {\n    docId: doc.docId,\n    status: 'generated',\n    department: doc.departmentKey,\n\n    invoiceNumber: d.invoiceNumber,\n    documentType: d.type,\n    issueDate: d.issueDate,\n    dueDate: d.dueDate ?? null,\n    netAmount: d.subtotal,\n    grossAmount: d.total,\n    currency: d.currency,\n    seller: d.seller.name,\n    buyer: d.buyer.name,\n    buyerReference: d.countrySpecific?.buyerReference ?? null,\n\n    format: gen.format,\n    warnings: gen.warnings ?? [],\n    hasEmbeddedXml: Boolean(gen.embeddedXml),\n    template: $('Select PDF Template').item.json.templateName ?? null,\n\n    fingerprint,\n    fileName,\n    targetPath,\n    logPath: dep.logPath,\n    recipient,\n    sender: dep.senderMail,\n    departmentErrorMail: dep.errorMail,\n    subject: `${kind} ${d.invoiceNumber} – ${d.seller.name}`,\n    mailText,\n\n    sourceFile: doc.sourceFile,\n    sourceMail: doc.mail,\n  },\n  binary: $input.item.binary,\n};\n"
      },
      "name": "Prepare Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2300,
        240
      ]
    },
    {
      "parameters": {
        "operation": "write",
        "fileName": "={{ $json.targetPath }}",
        "dataPropertyName": "data",
        "options": {}
      },
      "name": "Store E-Invoice",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        3180,
        240
      ]
    },
    {
      "parameters": {
        "fromEmail": "={{ $json.sender }}",
        "toEmail": "={{ $json.recipient }}",
        "subject": "={{ $json.subject }}",
        "emailFormat": "text",
        "text": "={{ $json.mailText }}",
        "options": {
          "appendAttribution": false,
          "fileAttachments": "data"
        }
      },
      "name": "Send to Buyer",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        3400,
        240
      ],
      "credentials": {}
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// ── Build Log Line ────────────────────────────────────────────────────\n// Mode: \"Run Once for Each Item\"\n// One line in JSON Lines format, as binary, so the write node can append it\n// to the department's log file.\n\n// Send to Buyer replaces the item JSON with the SMTP result (accepted, messageId, …),\n// so the document data comes from the node that decided it is new (paired item).\nconst e = $('Check Duplicate').item.json;\nconst sent = $input.item.json;\n\nconst entry = {\n  timestamp: new Date().toISOString(),\n  execution: $execution.id,\n  workflow: $workflow.name,\n  status: 'ok',\n\n  department: e.department,\n  docId: e.docId,\n  fingerprint: e.fingerprint,\n  sourceFile: e.sourceFile,\n  sourceMailFrom: e.sourceMail?.from ?? null,\n  sourceMailSubject: e.sourceMail?.subject ?? null,\n\n  invoiceNumber: e.invoiceNumber,\n  documentType: e.documentType,\n  issueDate: e.issueDate,\n  dueDate: e.dueDate,\n  netAmount: e.netAmount,\n  grossAmount: e.grossAmount,\n  currency: e.currency,\n  seller: e.seller,\n  buyer: e.buyer,\n  buyerReference: e.buyerReference,\n\n  format: e.format,\n  template: e.template,\n  warnings: e.warnings,\n  storedAt: e.targetPath,\n  sentTo: e.recipient,\n  sentMessageId: sent?.messageId ?? null,\n};\n\nreturn {\n  json: { ...entry, logPath: e.logPath },\n  binary: {\n    logLine: {\n      data: Buffer.from(JSON.stringify(entry) + '\\n', 'utf8').toString('base64'),\n      mimeType: 'application/x-ndjson',\n      fileName: 'e-invoice.jsonl',\n    },\n  },\n};\n"
      },
      "name": "Build Log Line",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3620,
        240
      ]
    },
    {
      "parameters": {
        "operation": "write",
        "fileName": "={{ $json.logPath }}",
        "dataPropertyName": "logLine",
        "options": {
          "append": true
        }
      },
      "name": "Append Log",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        3840,
        240
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// ── Prepare Error ─────────────────────────────────────────────────────\n// Mode: \"Run Once for Each Item\"\n//\n// The document failed the in-workflow check, the API validation or the\n// generation. Nothing is sent to the buyer. Instead: a log line and a mail\n// back to the department that sent the document — they can fix it, the\n// buyer never sees any of this.\n\nconst response = $input.item.json;\nconst doc = $('Build EN 16931 Invoice').item.json;\nconst dep = doc.department || {};\nconst d = doc.invoiceData || {};\n\nconst ownCheck = doc.check?.messages ?? [];\n\nconst asText = (f) => {\n  const msg = f.message ?? JSON.stringify(f);\n  const code = f.code && !String(msg).startsWith(`[${f.code}]`) ? `[${f.code}] ` : '';\n  return `${code}${msg}${f.field ? ` (${f.field})` : ''}`;\n};\n\n// `errors` come from validation, `complianceErrors` from generation (Schematron\n// check of XRechnung/ZUGFeRD), `results[]` carries the per-format XSD+Schematron\n// verdict of /validate — collect all of them.\nconst apiErrors = [\n  ...(response.errors ?? []).map(asText),\n  ...(response.complianceErrors ?? []).map(asText),\n  ...(response.results ?? []).flatMap((r) => (r.valid === false ? (r.errors ?? []).map((f) => `[${r.format}] ${asText(f)}`) : [])),\n];\nif (response.error) apiErrors.push(typeof response.error === 'object' ? (response.error.message ?? JSON.stringify(response.error)) : String(response.error));\nif (response.data && response.data.approved === false) {\n  apiErrors.push(`Release refused by the reviewer${response.data.respondedAt ? ` (${response.data.respondedAt})` : ''} — nothing was generated or sent.`);\n}\nconst apiWarnings = (response.warnings ?? []).map((w) => w.message ?? JSON.stringify(w));\n\nconst allMessages = [...ownCheck, ...apiErrors];\n\nconst entry = {\n  timestamp: new Date().toISOString(),\n  execution: $execution.id,\n  workflow: $workflow.name,\n  status: 'error',\n\n  department: doc.departmentKey,\n  docId: doc.docId,\n  sourceFile: doc.sourceFile,\n  sourceMailFrom: doc.mail?.from ?? null,\n  sourceMailSubject: doc.mail?.subject ?? null,\n\n  invoiceNumber: d.invoiceNumber ?? null,\n  documentType: d.type ?? null,\n  grossAmount: d.total ?? null,\n  seller: d.seller?.name ?? null,\n  buyer: d.buyer?.name ?? null,\n\n  ownCheck,\n  apiErrors,\n  apiWarnings,\n};\n\nconst mailText = [\n  'a document from your mailbox could not be turned into an e-invoice.',\n  '',\n  `Department:   ${doc.departmentKey} (${dep.name ?? '–'})`,\n  `Source file:  ${doc.sourceFile}`,\n  `From mail by: ${doc.mail?.from ?? 'unknown'}`,\n  `Subject:      ${doc.mail?.subject ?? '–'}`,\n  `Document:     ${d.invoiceNumber ?? 'no number'}` +\n    (doc.docCount > 1 ? ` (document ${doc.docIndex} of ${doc.docCount}, page ${doc.page})` : ''),\n  `Execution:    ${$execution.id}`,\n  '',\n  'Problems found:',\n  ...allMessages.map((m) => `  · ${m}`),\n  ...(apiWarnings.length ? ['', 'Warnings:', ...apiWarnings.map((m) => `  · ${m}`)] : []),\n  '',\n  'Nothing was sent to the buyer. The source PDF is stored unchanged under',\n  `${dep.archivePath ?? '–'}.`,\n  'After correcting it, simply send the mail to this mailbox again.',\n].join('\\n');\n\nreturn {\n  json: {\n    ...entry,\n    logPath: dep.logPath,\n    recipient: dep.errorMail,\n    sender: dep.senderMail,\n    subject: `[E-Invoice] Document rejected: ${d.invoiceNumber ?? doc.sourceFile}`,\n    mailText,\n    messageCount: allMessages.length,\n  },\n  binary: {\n    logLine: {\n      data: Buffer.from(JSON.stringify(entry) + '\\n', 'utf8').toString('base64'),\n      mimeType: 'application/x-ndjson',\n      fileName: 'e-invoice.jsonl',\n    },\n  },\n};\n"
      },
      "name": "Prepare Error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        620
      ]
    },
    {
      "parameters": {
        "operation": "write",
        "fileName": "={{ $json.logPath }}",
        "dataPropertyName": "logLine",
        "options": {
          "append": true
        }
      },
      "name": "Append Error Log",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        1420,
        620
      ]
    },
    {
      "parameters": {
        "fromEmail": "={{ $json.sender }}",
        "toEmail": "={{ $json.recipient }}",
        "subject": "={{ $json.subject }}",
        "emailFormat": "text",
        "text": "={{ $json.mailText }}",
        "options": {
          "appendAttribution": false
        }
      },
      "name": "Notify Department",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        1640,
        620
      ],
      "credentials": {}
    },
    {
      "parameters": {
        "content": "## Mailbox → PDF → ZUGFeRD / XRechnung\n\nEach department mails its **PDF invoices** (from Word or Excel) to its own mailbox. This workflow reads the PDF with a handful of regular expressions, rebuilds the invoice as EN 16931 data, lets `invoice-api.xhub` validate and generate the e-invoice, sends it to the buyer and writes one log line per document.\n\nBuilt in: a duplicate guard across runs (note 6). Optional: a four-eyes release by mail (node **Request Release**, disabled by default).\n\n**Before you activate** — fill the two tables in **Load Master Data** (your department + your customers), connect IMAP, SMTP and invoice-api.xhub, create the output directories (note 6) and send the example PDF to the mailbox once.\n\nRequires the community node `n8n-nodes-invoice-api-xhub` ≥ 1.1.3.",
        "height": 440,
        "width": 620,
        "color": 4
      },
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1060,
        -670
      ]
    },
    {
      "parameters": {
        "content": "## 1 · One mailbox per department\nEvery department sends its invoices to its own address. The Set node behind the trigger sets exactly one field: `department`. Everything else comes from master data.\n\n**Adding a department — four steps:**\n1. Copy the IMAP trigger, enter the new mailbox credentials\n2. Copy the Set node, set `department` to the new key\n3. Add an entry under `DEPARTMENTS` in **Load Master Data**\n4. Add a parser for the department's layout in **Read Document**\n\nAll branches continue into the same nodes — there is one pipeline, not one per department.",
        "height": 360,
        "width": 460
      },
      "name": "1 · Mailboxes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1060,
        -230
      ]
    },
    {
      "parameters": {
        "content": "## 2 · The only node you maintain\n**Load Master Data** holds two tables:\n\n**DEPARTMENTS** — seller, bank account, contact person, output paths, target format, error recipient, template.\n\n**CUSTOMERS** — e-mail address and Leitweg-ID / buyer reference per buyer.\n\n⚠️ The customer table is not optional. **Neither the buyer's e-mail address nor the Leitweg-ID is printed on an incoming PDF.** Without an entry there is no recipient and no buyer reference (BT-10, BR-DE-15) — the document goes to the error path instead of to the customer. That is intentional.",
        "height": 430,
        "width": 420
      },
      "name": "2 · Master data",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -580,
        -300
      ]
    },
    {
      "parameters": {
        "content": "## 3 · Reading without an LLM\n**Extract from File** pulls the text, **Read Document** evaluates it with rules — one parser per department layout.\n\nThis works because the documents come out of Word/Excel templates and look the same every time. Compared to a language model: **repeatable, free, no external service** — the same file always gives the same result.\n\n`Join Pages` is OFF: one PDF may contain several invoices, one per page. Each page is parsed on its own; pages without a document (attachments) yield nothing and do no harm.\n\nA parser that cannot find a field **does not guess** — it returns null. Missing fields surface in the next node, by name.\n\n⚠️ Do **not** set `Max Pages` on the Extract node. In n8n 2.x, `0` means *zero pages*, not *all pages* — the node returns empty text with a green status. Leave the option out.",
        "height": 500,
        "width": 560
      },
      "name": "3 · Reading",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -140,
        -370
      ]
    },
    {
      "parameters": {
        "content": "## 4 · Assemble and recompute\n**Build EN 16931 Invoice** joins three sources:\n\n| From | What |\n|---|---|\n| Document | number, dates, line items, totals, address |\n| Department | seller, VAT ID, bank account, contact |\n| Customer table | e-mail address, Leitweg-ID |\n\nThe numbers are **recomputed**: items → net → VAT → gross, tolerance 2 cents.\n\nCredit note detected → `type: credit_note` with **positive** amounts (EN 16931: the sign belongs to the document type, BR-27 forbids negative unit prices).\n\nA different VAT ID on the document than in master data means the mail hit the wrong mailbox — that is reported too.",
        "height": 690,
        "width": 700
      },
      "name": "4 · Build",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        440,
        -560
      ]
    },
    {
      "parameters": {
        "content": "## 5 · Visual layout\n**Select PDF Template** builds the BlockTemplate per document, so long-form dates and country names can be rendered as finished text (the XML keeps the normalised values).\n\nThe template declares **`lengthUnit: 'pt'`** — always set it. A template without it is read in legacy mode: page margins and spacer heights become millimetres while everything else stays points.\n\n`locale` is set on **every summary row** — that is where number formatting is read (`18.391,71` instead of `18,391.71`).\n\nSet `templateId` in master data to use a template from your invoice-api.xhub account instead; then the layout is maintained in the account, not in the workflow.",
        "height": 480,
        "width": 460
      },
      "name": "5 · Template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1600,
        -260
      ]
    },
    {
      "parameters": {
        "content": "## 6 · Output\nGenerate → store → send to the buyer → log.\n\nThe directories must **exist**, the write node does not create them:\n```\nmkdir -p /home/node/e-invoice/{in,out,log}/musterfirma\n```\nSelf-hosted n8n ≥ 1.x also needs `N8N_RESTRICT_FILE_ACCESS_TO=/home/node/e-invoice`.\n\n⚠️ The fingerprint in the file name is computed in **Prepare Result**, **not** taken from the API's `hash` — that one is the SHA-256 of the generated bytes and changes on every call (PDF timestamp). Same document twice → same file name.\n\nThe log is JSON Lines: one line per document, one file per department.\n\n**Duplicate guard across runs** — before anything is stored or sent, **Check Duplicate** looks the fingerprint up in that log. A `status: \"ok\"` line with the same fingerprint means the document already went out: the run ends with a `status: \"duplicate\"` line and a short mail to the department, **nothing is sent twice**. The same mail arriving twice is therefore harmless.",
        "height": 640,
        "width": 620
      },
      "name": "6 · Output",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3100,
        -480
      ]
    },
    {
      "parameters": {
        "content": "## 7 · What does not pass\nThree gates, and the difference matters:\n\n1. **Build EN 16931 Invoice** — own check: totals add up, mandatory fields, customer known\n2. **Validate** — `valid` = object-level rules · `results[]` = XSD + Schematron per format. **Approved?** checks `valid` and the `results[]` of the department's **target format** family (a ZUGFeRD document is not held back by an XRechnung-only rule; every result still lands in the error mail).\n3. **Generate** — has an error output into the same path (HTTP 422 with field-level messages)\n\nOptional fourth gate: **Request Release (E-Mail)** — disabled by default, see the note next to it. A rejected release ends in the same error path.\n\nIf a document fails, **nothing is sent to the buyer**. Instead: a `status: \"error\"` line in the department's log, a plain-language mail **back to the department** that sent it, and the source PDF stays unchanged in the archive. Fix it, send the mail again.\n\nField names in 422 errors (`errors[].field`) arrive with community node ≥ 1.1.3.",
        "height": 470,
        "width": 640
      },
      "name": "7 · Errors",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1120,
        780
      ]
    },
    {
      "parameters": {
        "operation": "sendAndWait",
        "fromEmail": "={{ $('Build EN 16931 Invoice').item.json.department.senderMail }}",
        "toEmail": "={{ $('Build EN 16931 Invoice').item.json.department.releaseMail || $('Build EN 16931 Invoice').item.json.department.errorMail }}",
        "subject": "=[E-Invoice] Release requested: {{ $('Build EN 16931 Invoice').item.json.invoiceData.invoiceNumber }} — {{ $('Build EN 16931 Invoice').item.json.invoiceData.buyer.name }}",
        "message": "=A document passed all checks and is waiting for your release before it is generated and sent.\n\nDepartment: {{ $('Build EN 16931 Invoice').item.json.departmentKey }}\nDocument:   {{ $('Build EN 16931 Invoice').item.json.invoiceData.type === 'credit_note' ? 'Credit note' : 'Invoice' }} {{ $('Build EN 16931 Invoice').item.json.invoiceData.invoiceNumber }} dated {{ $('Build EN 16931 Invoice').item.json.invoiceData.issueDate }}\nBuyer:      {{ $('Build EN 16931 Invoice').item.json.invoiceData.buyer.name }}\nGross:      {{ $('Build EN 16931 Invoice').item.json.invoiceData.total }} {{ $('Build EN 16931 Invoice').item.json.invoiceData.currency }}\nSource:     {{ $('Build EN 16931 Invoice').item.json.sourceFile }}\n\nRelease = generate the e-invoice and send it to the buyer. Reject = nothing is sent; the department gets a note.",
        "responseType": "approval",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "Release",
            "disapproveLabel": "Reject",
            "buttonApprovalStyle": "primary",
            "buttonDisapprovalStyle": "secondary"
          }
        },
        "options": {
          "appendAttribution": false,
          "limitWaitTime": {
            "values": {
              "limitType": "afterTimeInterval",
              "resumeAmount": 3,
              "resumeUnit": "days"
            }
          }
        }
      },
      "name": "Request Release (E-Mail)",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        1200,
        240
      ],
      "disabled": true,
      "webhookId": "release-06",
      "notes": "Optional four-eyes step. Enable this node to pause every document until a reviewer clicks Release in the mail."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "released",
              "leftValue": "={{ $json.data?.approved !== false }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "name": "Released?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1420,
        240
      ]
    },
    {
      "parameters": {
        "fileSelector": "={{ $('Prepare Result').item.json.logPath }}",
        "options": {
          "dataPropertyName": "log"
        }
      },
      "name": "Read Department Log",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        2520,
        240
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ── Check Duplicate ───────────────────────────────────────────────────\n// Mode: \"Run Once for All Items\"\n//\n// Duplicate guard ACROSS runs. The file name already carries a content\n// fingerprint (Prepare Result), so storing twice is harmless — but sending\n// twice is not. Before anything leaves the system, the fingerprint is looked\n// up in the department's JSONL log: a `status: \"ok\"` line with the same\n// fingerprint means this exact document was already sent.\n//\n// Older log lines (before the fingerprint field existed) are matched on the\n// stored file path instead, which contains the same fingerprint.\n\nconst docs = $('Prepare Result').all();\n\nlet text = '';\nconst inputs = $input.all();\nfor (let i = 0; i < inputs.length; i++) {\n  if (inputs[i].binary?.log) {\n    text = (await this.helpers.getBinaryDataBuffer(i, 'log')).toString('utf8');\n    break;\n  }\n}\nconst entries = text.split('\\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);\n\nreturn docs.map((doc, i) => {\n  const fp = doc.json.fingerprint;\n  const prev = entries.find((e) => e.status === 'ok' && (e.fingerprint === fp || e.storedAt === doc.json.targetPath));\n  return {\n    json: {\n      ...doc.json,\n      duplicate: Boolean(prev),\n      duplicateOf: prev ? { execution: prev.execution, timestamp: prev.timestamp, sentTo: prev.sentTo ?? null } : null,\n    },\n    binary: doc.binary,\n    pairedItem: { item: Math.min(i, inputs.length - 1) },\n  };\n});\n"
      },
      "name": "Check Duplicate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2740,
        240
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "not-dup",
              "leftValue": "={{ $json.duplicate === true }}",
              "rightValue": false,
              "operator": {
                "type": "boolean",
                "operation": "false",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "name": "New Document?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2960,
        240
      ]
    },
    {
      "parameters": {
        "jsCode": "// ── Prepare Duplicate Notice ──────────────────────────────────────────\n// Mode: \"Run Once for Each Item\"\n// The same document was already sent in an earlier run (same fingerprint in\n// the department's log). Nothing goes to the buyer a second time; the\n// department gets a short note and the log records the duplicate.\n\nconst e = $input.item.json;\n\nconst entry = {\n  timestamp: new Date().toISOString(),\n  execution: $execution.id,\n  workflow: $workflow.name,\n  status: 'duplicate',\n\n  department: e.department,\n  docId: e.docId,\n  fingerprint: e.fingerprint,\n  sourceFile: e.sourceFile,\n  sourceMailFrom: e.sourceMail?.from ?? null,\n  sourceMailSubject: e.sourceMail?.subject ?? null,\n\n  invoiceNumber: e.invoiceNumber,\n  documentType: e.documentType,\n  grossAmount: e.grossAmount,\n  currency: e.currency,\n  buyer: e.buyer,\n\n  duplicateOf: e.duplicateOf,\n};\n\nconst mailText = [\n  'a document from your mailbox was already sent as an e-invoice in an earlier run.',\n  '',\n  `Department:   ${e.department}`,\n  `Source file:  ${e.sourceFile}`,\n  `Document:     ${e.invoiceNumber} (${e.documentType}) · ${e.grossAmount} ${e.currency}`,\n  `First sent:   ${e.duplicateOf?.timestamp ?? '–'} (execution ${e.duplicateOf?.execution ?? '–'})` +\n    (e.duplicateOf?.sentTo ? ` to ${e.duplicateOf.sentTo}` : ''),\n  `Execution:    ${$execution.id}`,\n  '',\n  'Nothing was sent again. If the document really changed, the content changes',\n  'too and it gets a new fingerprint — then it goes out as a new document.',\n].join('\\n');\n\nreturn {\n  json: {\n    ...entry,\n    logPath: e.logPath,\n    recipient: e.departmentErrorMail,\n    sender: e.sender,\n    subject: `[E-Invoice] Already sent: ${e.invoiceNumber}`,\n    mailText,\n  },\n  binary: {\n    logLine: {\n      data: Buffer.from(JSON.stringify(entry) + '\\n', 'utf8').toString('base64'),\n      mimeType: 'application/x-ndjson',\n      fileName: 'e-invoice.jsonl',\n    },\n  },\n};\n"
      },
      "name": "Prepare Duplicate Notice",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3180,
        460
      ]
    },
    {
      "parameters": {
        "operation": "write",
        "fileName": "={{ $json.logPath }}",
        "dataPropertyName": "logLine",
        "options": {
          "append": true
        }
      },
      "name": "Append Log (Duplicate)",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        3400,
        460
      ]
    },
    {
      "parameters": {
        "fromEmail": "={{ $json.sender }}",
        "toEmail": "={{ $json.recipient }}",
        "subject": "={{ $json.subject }}",
        "emailFormat": "text",
        "text": "={{ $json.mailText }}",
        "options": {
          "appendAttribution": false
        }
      },
      "name": "Notify Department (Duplicate)",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        3620,
        460
      ],
      "credentials": {}
    },
    {
      "parameters": {
        "content": "## Release (optional)\n**Request Release (E-Mail)** pauses the run and mails the reviewer (`releaseMail` in master data, else `errorMail`) two buttons: **Release** generates and sends, **Reject** ends in the error path with a note to the department. Waits up to 3 days.\n\nOff by default — enable the node for a four-eyes principle. One mail per mailbox run: if a run carries several documents, they are released together.",
        "height": 300,
        "width": 420,
        "color": 4
      },
      "name": "5b · Release",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1160,
        -100
      ]
    }
  ],
  "connections": {
    "Mailbox: Musterfirma (IMAP)": {
      "main": [
        [
          {
            "node": "Department: musterfirma",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Department: musterfirma": {
      "main": [
        [
          {
            "node": "Load Master Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Master Data": {
      "main": [
        [
          {
            "node": "Split PDF Attachments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split PDF Attachments": {
      "main": [
        [
          {
            "node": "Archive Source PDF",
            "type": "main",
            "index": 0
          },
          {
            "node": "Extract from File (PDF)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract from File (PDF)": {
      "main": [
        [
          {
            "node": "Clean Text & Detect Scans",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clean Text & Detect Scans": {
      "main": [
        [
          {
            "node": "Read Document (rule-based)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Document (rule-based)": {
      "main": [
        [
          {
            "node": "Build EN 16931 Invoice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build EN 16931 Invoice": {
      "main": [
        [
          {
            "node": "invoice-api.xhub: Validate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "invoice-api.xhub: Validate": {
      "main": [
        [
          {
            "node": "Approved?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approved?": {
      "main": [
        [
          {
            "node": "Request Release (E-Mail)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Select PDF Template": {
      "main": [
        [
          {
            "node": "invoice-api.xhub: Generate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "invoice-api.xhub: Generate": {
      "main": [
        [
          {
            "node": "Generated?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generated?": {
      "main": [
        [
          {
            "node": "Prepare Result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Result": {
      "main": [
        [
          {
            "node": "Read Department Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Store E-Invoice": {
      "main": [
        [
          {
            "node": "Send to Buyer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send to Buyer": {
      "main": [
        [
          {
            "node": "Build Log Line",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Log Line": {
      "main": [
        [
          {
            "node": "Append Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Error": {
      "main": [
        [
          {
            "node": "Append Error Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append Error Log": {
      "main": [
        [
          {
            "node": "Notify Department",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Request Release (E-Mail)": {
      "main": [
        [
          {
            "node": "Released?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Released?": {
      "main": [
        [
          {
            "node": "Select PDF Template",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Department Log": {
      "main": [
        [
          {
            "node": "Check Duplicate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Duplicate": {
      "main": [
        [
          {
            "node": "New Document?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New Document?": {
      "main": [
        [
          {
            "node": "Store E-Invoice",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Duplicate Notice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Duplicate Notice": {
      "main": [
        [
          {
            "node": "Append Log (Duplicate)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append Log (Duplicate)": {
      "main": [
        [
          {
            "node": "Notify Department (Duplicate)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  }
}
