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
+19
View File
@@ -164,6 +164,25 @@ BEGIN
ALTER TABLE costs ADD CONSTRAINT costs_building_fkey
FOREIGN KEY (building) REFERENCES buildings(name) ON UPDATE CASCADE;
END $$;
-- Gescannte Belege aus SparkReceipt (Cloud-Archiv), per Webhook importiert.
-- (quelle, externe_id) eindeutig → wiederholte Events aktualisieren statt zu duplizieren.
CREATE TABLE IF NOT EXISTS belege (
id TEXT PRIMARY KEY,
quelle TEXT NOT NULL DEFAULT 'sparkreceipt',
externe_id TEXT NOT NULL,
haendler TEXT NOT NULL DEFAULT '',
betrag NUMERIC(10,2),
waehrung TEXT NOT NULL DEFAULT 'EUR',
datum TEXT NOT NULL DEFAULT '',
jahr INT,
kategorie TEXT NOT NULL DEFAULT '',
notiz TEXT NOT NULL DEFAULT '',
datei_url TEXT NOT NULL DEFAULT '',
zugeordnet BOOLEAN NOT NULL DEFAULT FALSE,
erfasst_am TEXT NOT NULL,
UNIQUE (quelle, externe_id)
);
`
async function init() {
+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 = []