{
  "name": "rechnungen-extract",
  "nodes": [
    {
      "parameters": {
        "content": "## 🧾 Rechnungen automatisch erfassen\n\nPDF landet im `Dokumente/Landing/` → Download via WebDAV → OCR via embedding-service → LLM klassifiziert + extrahiert Felder (Lieferant, Brutto, Datum, Rechnungsnummer) → Datei wird zu `Dokumente/Rechnungen/{datum}_{lieferant}_{rnr}.pdf` umbenannt + verschoben → Zeile in Nextcloud Tables → File-Share in `#Rechnungen`-Talk mit Caption + Ordner-Link.",
        "width": 1660,
        "color": 7
      },
      "id": "sticky-intro",
      "name": "Beschreibung",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -384,
        -128
      ]
    },
    {
      "parameters": {
        "inputSource": "passthrough"
      },
      "id": "trigger-1",
      "name": "On Run",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "typeVersion": 1.1,
      "position": [
        -480,
        192
      ]
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst node = item.node || item.body?.node || {};\nconst path = node.path || '';\nconst ext = (path.toLowerCase().match(/\\.[a-z0-9]+$/) || [''])[0];\nconst MIME = {\n  '.pdf':  'application/pdf',\n  '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n  '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n  '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n};\nconst mime = MIME[ext] || null;\nconst user = node.user_id || 'admin';\nconst userSeg = '/' + user + '/files/';\nlet rel = path;\nif (path.startsWith(userSeg)) rel = path.slice(userSeg.length);\nelse if (path.startsWith('/')) rel = path.slice(1);\nconst filename = rel.split('/').pop();\nconst folder = '/' + rel.split('/').slice(0,-1).join('/');\nconst NC = 'https://nextcloud.oliverkoehn.com';\nconst webdav_url = NC + '/remote.php/dav/files/' + user + '/' + rel.split('/').map(encodeURIComponent).join('/');\nconst folder_url = NC + '/index.php/apps/files/?dir=' + encodeURIComponent(folder);\nconst web_url    = folder_url + '&openfile=' + encodeURIComponent(filename);\nreturn [{ json: { file_id: String(node.id || ''), path, user_id: user, ext, mime, filename, folder, webdav_url, folder_url, web_url } }];"
      },
      "id": "build-url-1",
      "name": "Build URL",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -240,
        192
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.webdav_url }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "id": "download-1",
      "name": "Download (WebDAV)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        560,
        208
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://embedding-service:8000/extract",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "data"
            },
            {
              "name": "mime",
              "value": "={{ $('Build URL').first().json.mime }}"
            }
          ]
        },
        "options": {
          "timeout": 120000
        }
      },
      "id": "extract-1",
      "name": "Extract (embedding-svc)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        848,
        208
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://litellm:4000/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({\n  model: 'gpt-4o-mini',\n  messages: [\n    { role: 'system', content: 'Du klassifizierst deutsche Geschaeftsdokumente. Wenn das Dokument eine Rechnung ist, gib class=\\\"Rechnung\\\" und extrahiere alle Felder. WICHTIG: datum IMMER im ISO Format YYYY-MM-DD (z.B. 14.05.2026 -> 2026-05-14). betrag_netto/mwst_satz/betrag_brutto immer als Zahl ohne Tausenderzeichen. Gib confidence (0..1) als Selbsteinschätzung der Sicherheit. Bei klarer Rechnung mit allen Feldern: confidence >= 0.85. Bei unklarem/unvollstaendigem Dokument: confidence < 0.5. Sonst class=\\\"Sonstiges\\\" und alle Felder null.' },\n    { role: 'user', content: $('Extract (embedding-svc)').first().json.text.substring(0, 12000) }\n  ],\n  response_format: {\n    type: 'json_schema',\n    json_schema: {\n      name: 'rechnung_or_other',\n      strict: true,\n      schema: {\n        type: 'object',\n        additionalProperties: false,\n        required: ['class','fields','confidence'],\n        properties: {\n          class: { type: 'string', enum: ['Rechnung','Sonstiges'] },\n          confidence: { type: 'number', description: 'Vertrauen 0..1 dass die Klassifikation und Felder korrekt sind' },\n          fields: {\n            type: 'object',\n            additionalProperties: false,\n            required: ['rechnungsnummer','datum','lieferant','betrag_netto','mwst_satz','betrag_brutto','waehrung','kategorie'],\n            properties: {\n              rechnungsnummer: { type: ['string','null'] },\n              datum: { type: ['string','null'] },\n              lieferant: { type: ['string','null'] },\n              betrag_netto: { type: ['number','null'] },\n              mwst_satz: { type: ['number','null'] },\n              betrag_brutto: { type: ['number','null'] },\n              waehrung: { type: ['string','null'] },\n              kategorie: { type: ['string','null'], enum: ['Material','Software','Reise','Sonstiges',null] }\n            }\n          }\n        }\n      }\n    }\n  }\n}) }}",
        "options": {
          "timeout": 60000
        }
      },
      "id": "llm-1",
      "name": "LLM Classify+Extract",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1120,
        208
      ]
    },
    {
      "parameters": {
        "jsCode": "const raw = $input.first().json.choices[0].message.content;\nconst out = JSON.parse(raw);\nreturn [{ json: out }];"
      },
      "id": "parse-1",
      "name": "Parse LLM",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1408,
        208
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 1
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.class }}",
              "rightValue": "Rechnung",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-1",
      "name": "If Rechnung",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        0,
        640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://nextcloud.oliverkoehn.com/ocs/v2.php/apps/tables/api/2/tables/3/rows",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "OCS-APIRequest",
              "value": "true"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({\n  data: {\n    '16': $('Parse LLM').first().json.fields.rechnungsnummer,\n    '17': $('Parse LLM').first().json.fields.datum,\n    '18': $('Parse LLM').first().json.fields.lieferant,\n    '19': $('Parse LLM').first().json.fields.betrag_netto,\n    '20': $('Parse LLM').first().json.fields.mwst_satz,\n    '21': $('Parse LLM').first().json.fields.betrag_brutto,\n    '22': $('Parse LLM').first().json.fields.waehrung || 'EUR',\n    '23': ({'Material':1,'Software':2,'Reise':3,'Sonstiges':4}[$('Parse LLM').first().json.fields.kategorie] || 4).toString(),\n    '24': $('New Path').first().json.new_web_url,\n    '25': $('New Path').first().json.status_id,\n    '26': String($('Build URL').first().json.file_id)\n  }\n}) }}",
        "options": {}
      },
      "id": "tables-1",
      "name": "Tables Insert Rechnung",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1120,
        640
      ]
    },
    {
      "parameters": {
        "jsCode": "function slugify(s) {\n  if (!s) return 'unbekannt';\n  return String(s).toLowerCase()\n    .replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')\n    .replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,40) || 'unbekannt';\n}\nconst meta = $('Build URL').first().json;\nconst parsed = $input.first().json;\nconst fields = parsed.fields || {};\nconst conf   = typeof parsed.confidence === 'number' ? parsed.confidence : 0;\nconst isConfident = conf >= 0.7;\n// Sinnvoller filename: {datum}_{lieferant-slug}_{rnr}.{ext}\nconst ext = meta.ext || '.pdf';\nconst datum    = fields.datum || meta.filename?.slice(0,10) || 'undatiert';\nconst lief     = slugify(fields.lieferant);\nconst rnr      = slugify(fields.rechnungsnummer || 'ohne-nr');\nconst new_filename = `${datum}_${lief}_${rnr}${ext}`;\nconst new_folder   = isConfident ? '/Dokumente/Rechnungen' : '/Dokumente/Sonstiges/Manuell-Prüfen';\nconst new_rel      = (isConfident ? 'Dokumente/Rechnungen/' : 'Dokumente/Sonstiges/Manuell-Prüfen/') + new_filename;\nconst new_path     = '/' + meta.user_id + '/files/' + new_rel;\nconst NC = 'https://nextcloud.oliverkoehn.com';\nconst new_webdav_url = NC + '/remote.php/dav/files/' + meta.user_id + '/' + new_rel.split('/').map(encodeURIComponent).join('/');\nconst new_web_url    = NC + '/index.php/apps/files/?dir=' + encodeURIComponent(new_folder) + '&openfile=' + encodeURIComponent(new_filename);\nconst new_folder_url = NC + '/index.php/apps/files/?dir=' + encodeURIComponent(new_folder);\n// Tables-Status: 1=neu wenn confident, 4=unsicher sonst\nconst status_id = isConfident ? '1' : '4';\nreturn [{ json: { fields, confidence: conf, isConfident, new_path, new_rel, new_filename, new_folder, new_webdav_url, new_web_url, new_folder_url, status_id } }];"
      },
      "id": "new-path-1",
      "name": "New Path",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        288,
        640
      ]
    },
    {
      "parameters": {
        "method": "MOVE",
        "url": "={{ $('Build URL').first().json.webdav_url }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Destination",
              "value": "={{ $('New Path').first().json.new_webdav_url }}"
            },
            {
              "name": "Overwrite",
              "value": "T"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "fullResponse": true
            }
          }
        }
      },
      "id": "move-1",
      "name": "Move to Rechnungen/",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        560,
        640
      ],
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "const meta = $('New Path').first().json;\nconst moveResp = $input.first().json || {};\nconst headers = moveResp.headers || {};\nconst ocFid = headers['oc-fileid'] || headers['OC-FileId'] || headers['Oc-Fileid'] || '';\nconst m = ocFid.match(/^0*([0-9]+)/);\nconst fileid = m ? m[1] : null;\nconst preview_url = fileid\n  ? ('https://nextcloud.oliverkoehn.com/index.php/f/' + fileid)\n  : meta.new_web_url;\nreturn [{ json: Object.assign({}, meta, { fileid, preview_url, oc_fileid_raw: ocFid }) }];"
      },
      "id": "parse-fid",
      "name": "Parse FileId",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        848,
        640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://nextcloud.oliverkoehn.com/ocs/v2.php/apps/files_sharing/api/v1/shares",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "OCS-APIRequest",
              "value": "true"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "name": "shareType",
              "value": "10"
            },
            {
              "name": "shareWith",
              "value": "6h49sjrh"
            },
            {
              "name": "path",
              "value": "={{ '/Dokumente/Rechnungen/' + $('Parse FileId').first().json.new_filename }}"
            },
            {
              "name": "talkMetaData",
              "value": "={{ JSON.stringify({caption: '# 🧾 ' + $('Parse FileId').first().json.new_filename + '\\n\\n**Neue Rechnung**' + ($('New Path').first().json.isConfident ? '' : ' — bitte prüfen') + '\\n\\n**Lieferant:** ' + ($('Parse LLM').first().json.fields.lieferant || '?') + '\\n**Betrag:** ' + ($('Parse LLM').first().json.fields.betrag_brutto || '?') + ' ' + ($('Parse LLM').first().json.fields.waehrung || 'EUR') + ' (netto ' + ($('Parse LLM').first().json.fields.betrag_netto || '?') + ', MwSt ' + ($('Parse LLM').first().json.fields.mwst_satz || '?') + '%)' + '\\n**Datum:** ' + ($('Parse LLM').first().json.fields.datum || '?') + '\\n**Rechnungsnummer:** ' + ($('Parse LLM').first().json.fields.rechnungsnummer || '?') + '\\n\\n' + (  $('Resolve source').first().json.source === 'mail' ?     ('📥 **Quelle:** per E-Mail' +      ($('Resolve source').first().json.mail?.from ? '\\n📧 ' + ($('Resolve source').first().json.mail.from_name && $('Resolve source').first().json.mail.from_name !== $('Resolve source').first().json.mail.from ? $('Resolve source').first().json.mail.from_name + ' <' + $('Resolve source').first().json.mail.from + '>' : $('Resolve source').first().json.mail.from) : '') +      ($('Resolve source').first().json.mail?.subject ? '\\n📨 *' + $('Resolve source').first().json.mail.subject + '*' : '')) :     '📥 **Quelle:** Manuell hochgeladen') + '\\n\\n📁 [' + $('New Path').first().json.new_folder + '](' + $('New Path').first().json.new_folder_url + ')'}) }}"
            }
          ]
        },
        "options": {}
      },
      "id": "share-rech",
      "name": "Share Datei in #Rechnungen",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1408,
        640
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "url": "=https://nextcloud.oliverkoehn.com/remote.php/dav/files/admin/Dokumente/Landing/.email-meta/{{ encodeURIComponent($json.filename) }}.json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "text"
            }
          }
        }
      },
      "id": "check-mail-meta",
      "name": "Check mail-meta",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        0,
        192
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const meta = $('Build URL').first().json;\nconst resp = $input.first().json || {};\nlet source = 'manuell';\nlet mail = null;\nconst statusCode = resp.statusCode || resp.status || 0;\nif (statusCode >= 200 && statusCode < 300) {\n  let bodyStr = '';\n  if (typeof resp.body === 'string') bodyStr = resp.body;\n  else if (typeof resp.data === 'string') bodyStr = resp.data;\n  else if (resp.body && typeof resp.body === 'object') bodyStr = JSON.stringify(resp.body);\n  try {\n    const j = JSON.parse(bodyStr);\n    source = j.source || 'mail';\n    mail = {\n      from: j.from || '',\n      from_name: j.from_name || '',\n      subject: j.subject || '',\n      received_at: j.received_at || '',\n    };\n  } catch (e) {\n    // body nicht parsebar → bleibt manuell\n  }\n}\nreturn [{ json: Object.assign({}, meta, { source, mail }) }];"
      },
      "id": "resolve-source",
      "name": "Resolve source",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        272,
        208
      ]
    }
  ],
  "connections": {
    "On Run": {
      "main": [
        [
          {
            "node": "Build URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build URL": {
      "main": [
        [
          {
            "node": "Check mail-meta",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download (WebDAV)": {
      "main": [
        [
          {
            "node": "Extract (embedding-svc)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract (embedding-svc)": {
      "main": [
        [
          {
            "node": "LLM Classify+Extract",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM Classify+Extract": {
      "main": [
        [
          {
            "node": "Parse LLM",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse LLM": {
      "main": [
        [
          {
            "node": "If Rechnung",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Rechnung": {
      "main": [
        [
          {
            "node": "New Path",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Tables Insert Rechnung": {
      "main": [
        [
          {
            "node": "Share Datei in #Rechnungen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New Path": {
      "main": [
        [
          {
            "node": "Move to Rechnungen/",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Move to Rechnungen/": {
      "main": [
        [
          {
            "node": "Parse FileId",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse FileId": {
      "main": [
        [
          {
            "node": "Tables Insert Rechnung",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check mail-meta": {
      "main": [
        [
          {
            "node": "Resolve source",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve source": {
      "main": [
        [
          {
            "node": "Download (WebDAV)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "versionId": "5c372f1a-5d63-4b7d-8c49-35bd5e646e7b"
}