v1.16: SparkReceipt-Schnittstelle - Belege per Webhook importieren und als Kostenposition uebernehmen

This commit is contained in:
2026-10-04 12:43:36 +02:00
parent 189ce10d65
commit 4eab719550
7 changed files with 329 additions and 9 deletions
+81 -1
View File
@@ -21,6 +21,8 @@ const COOKIE_NAME = 'mp_session'
// KI-Antworten auf Schadensmeldungen über n8n (leer = deaktiviert)
const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || ''
const KI_API_TOKEN = process.env.KI_API_TOKEN || ''
// Beleg-Import aus SparkReceipt (leer = Webhook-Endpunkt deaktiviert)
const SPARKRECEIPT_WEBHOOK_TOKEN = process.env.SPARKRECEIPT_WEBHOOK_TOKEN || ''
// PDF-Ablage für das Dokumentenmodul (im Container per Volume persistiert)
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads')
const UPLOAD_MAX_BYTES = 25 * 1024 * 1024 // 25 MB pro Datei
@@ -182,6 +184,12 @@ const mapKosten = (k) => ({
kostenart: k.kostenart, betrag: Number(k.betrag), umlegbar: k.umlegbar,
notiz: k.notiz, erfasstAm: k.erfasst_am,
})
const mapBeleg = (b) => ({
id: b.id, quelle: b.quelle, externeId: b.externe_id, haendler: b.haendler,
betrag: b.betrag === null ? null : Number(b.betrag), waehrung: b.waehrung,
datum: b.datum, jahr: b.jahr, kategorie: b.kategorie, notiz: b.notiz,
dateiUrl: b.datei_url, zugeordnet: b.zugeordnet, erfasstAm: b.erfasst_am,
})
const deutschesDatum = () => new Date().toLocaleDateString('de-DE')
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
@@ -285,7 +293,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
try {
const istAdmin = req.user.rolle === 'verwaltung'
const einheit = req.user.einheit_id
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs] =
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege] =
await Promise.all([
pool.query('SELECT * FROM units ORDER BY id'),
istAdmin
@@ -319,6 +327,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
istAdmin
? pool.query('SELECT * FROM costs ORDER BY jahr DESC, building, kostenart')
: pool.query('SELECT * FROM costs WHERE unit_id = $1 ORDER BY jahr DESC, kostenart', [einheit]),
istAdmin
? pool.query('SELECT * FROM belege ORDER BY erfasst_am DESC')
: { rows: [] },
])
// Threads mit ihren Nachrichten zusammenführen
const nachrichtenProThread = {}
@@ -341,6 +352,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
meters: meters.rows.map(mapMeter),
buildings: buildings.rows.map(mapBuilding),
kosten: costs.rows.map(mapKosten),
belege: belege.rows.map(mapBeleg),
})
} catch (e) { next(e) }
})
@@ -712,6 +724,74 @@ app.delete('/api/costs/:id', auth, adminOnly, async (req, res, next) => {
} catch (e) { next(e) }
})
// ── Beleg-Import (SparkReceipt-Webhook) ──────────────────────
/** Tolerantes Extrahieren der Belegfelder: SparkReceipt schachtelt die
* Daten teils unter „document", teils flach – Aliase abdecken. */
function belegFelder(body) {
const d = (body && typeof body === 'object' && (body.document || body.receipt || body.data)) || body || {}
const nimm = (...keys) => {
for (const k of keys) {
if (d[k] !== undefined && d[k] !== null && d[k] !== '') return d[k]
}
return undefined
}
const betragRoh = nimm('total', 'amount', 'betrag', 'total_amount', 'sum', 'totalAmount')
const datumRoh = String(nimm('date', 'datum', 'purchase_date', 'created_at', 'document_date') ?? '')
const jahrRoh = nimm('year', 'jahr')
return {
externeId: String(nimm('id', 'document_id', 'documentId', 'receipt_id') ?? ''),
haendler: String(nimm('merchant', 'vendor', 'haendler', 'supplier', 'store_name', 'merchant_name', 'name') ?? ''),
betrag: betragRoh === undefined ? null : Number(betragRoh),
waehrung: String(nimm('currency', 'waehrung') ?? 'EUR'),
datum: datumRoh,
jahr: jahrRoh !== undefined ? Number(jahrRoh) : (datumRoh.match(/(19|20)\d{2}/) ? Number(datumRoh.match(/(19|20)\d{2}/)[0]) : null),
kategorie: String(nimm('category', 'kategorie') ?? ''),
notiz: String(nimm('notes', 'note', 'description', 'memo') ?? ''),
dateiUrl: String(nimm('file_url', 'fileUrl', 'image_url', 'receipt_url', 'document_url', 'url') ?? ''),
}
}
app.post('/api/belege/webhook', async (req, res) => {
try {
if (!SPARKRECEIPT_WEBHOOK_TOKEN) return res.status(503).json({ fehler: 'Beleg-Import ist nicht konfiguriert.' })
// SparkReceipt sendet nur eine URL (keine Custom-Headers) → Token per ?token=
const token = String(req.query.token || req.headers['x-webhook-token'] || '')
if (token !== SPARKRECEIPT_WEBHOOK_TOKEN) return res.status(401).json({ fehler: 'Ungültiger Token.' })
const event = String(req.body?.event || req.body?.event_type || req.body?.type || '')
if (/delete/i.test(event)) return res.json({ ok: true, ignoriert: event }) // Löschungen nicht importieren
const f = belegFelder(req.body)
if (!f.externeId) return res.status(400).json({ fehler: 'Keine Dokument-ID im Event.' })
if (f.betrag !== null && !Number.isFinite(f.betrag)) return res.status(400).json({ fehler: 'Betrag ungültig.' })
const { rows } = await pool.query(
`INSERT INTO belege (id, quelle, externe_id, haendler, betrag, waehrung, datum, jahr, kategorie, notiz, datei_url, erfasst_am)
VALUES ($1,'sparkreceipt',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (quelle, externe_id) DO UPDATE SET
haendler = EXCLUDED.haendler, betrag = EXCLUDED.betrag, waehrung = EXCLUDED.waehrung,
datum = EXCLUDED.datum, jahr = EXCLUDED.jahr, kategorie = EXCLUDED.kategorie,
notiz = EXCLUDED.notiz, datei_url = EXCLUDED.datei_url
RETURNING *`,
[`b-${Date.now()}`, f.externeId, f.haendler, f.betrag, f.waehrung, f.datum, f.jahr,
f.kategorie, f.notiz, f.dateiUrl, deutschesDatumZeit()],
)
res.json({ ok: true, beleg: mapBeleg(rows[0]), event: event || 'unknown' })
} catch (e) {
console.error('[belege] Webhook-Fehler:', e)
res.status(500).json({ fehler: 'Interner Serverfehler.' })
}
})
// Zugeordnet-Markierung für die Verwaltung (nach Übernahme als Kostenposition)
app.patch('/api/belege/:id', auth, adminOnly, async (req, res, next) => {
try {
const zugeordnet = Boolean((req.body || {}).zugeordnet)
const { rows } = await pool.query(
'UPDATE belege SET zugeordnet = $1 WHERE id = $2 RETURNING *', [zugeordnet, req.params.id],
)
if (!rows.length) return res.status(404).json({ fehler: 'Beleg nicht gefunden.' })
res.json({ beleg: mapBeleg(rows[0]) })
} catch (e) { next(e) }
})
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
function pruefeHaus(b) {
const fehler = []