From 4eab7195506e89bcd954719efe1aff0d688c6050 Mon Sep 17 00:00:00 2001
From: ah-admin
Date: Sun, 4 Oct 2026 12:43:36 +0200
Subject: [PATCH] v1.16: SparkReceipt-Schnittstelle - Belege per Webhook
importieren und als Kostenposition uebernehmen
---
docker-compose.yml | 2 +
kimi.md | 30 ++++++++
server/src/db.js | 19 +++++
server/src/index.js | 82 +++++++++++++++++++-
src/pages/Kosten.tsx | 173 +++++++++++++++++++++++++++++++++++++++++--
src/store/store.tsx | 15 ++++
src/types/index.ts | 17 +++++
7 files changed, 329 insertions(+), 9 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index 2f71ac9..3aab49b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -39,6 +39,8 @@ services:
N8N_TICKET_WEBHOOK: ${N8N_TICKET_WEBHOOK:-}
# Token, mit dem n8n die fertige KI-Antwort zurück an die API postet
KI_API_TOKEN: ${KI_API_TOKEN:-}
+ # Beleg-Import aus SparkReceipt (Shared Token für den Webhook)
+ SPARKRECEIPT_WEBHOOK_TOKEN: ${SPARKRECEIPT_WEBHOOK_TOKEN:-}
# PDF-Ablage im Dokumentenmodul (persistiertes Volume)
UPLOAD_DIR: /app/uploads
PORT: 3000
diff --git a/kimi.md b/kimi.md
index c356733..3a1e4cb 100644
--- a/kimi.md
+++ b/kimi.md
@@ -189,6 +189,36 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte
+**Erledigt 04.10. (v1.16):** SparkReceipt-Schnittstelle für gescannte Belege.
+SparkReceipt bietet Webhooks (Doku: sparkreceipt.com/integrations/webhooks)
+– POST auf eine URL bei document created/updated/deleted, Standard-Verzögerung
+10 min, Event-Log mit „Send now". Setup beim Nutzer: SparkReceipt →
+Integrationen → Webhooks → URL
+`https://mieterportal.hocks.eu/api/belege/webhook?token=`
+(Token liegt in /opt/mieterportal/portal.env, Stand 04.10.:
+0e8c671ca1c4df3668bac6b2b57602f49ad0f4db8366cc42 – bei Rotation Eintrag in
+portal.env ersetzen + `docker compose up -d`). NEUE TABELLE `belege`
+(quelle, externe_id, haendler, betrag, waehrung, datum, jahr, kategorie,
+notiz, datei_url, zugeordnet, erfasst_am; UNIQUE(quelle, externe_id)).
+API: POST /api/belege/webhook (KEINE Session – Token per ?token= ODER
+x-webhook-token-Header; ohne gesetztes Env → 503; toleranten Feld-Aliasen –
+Payload ggf. unter body.document verschachtelt, Aliase merchant/vendor/total/
+amount/date/category/file_url u. a.; delete-Events werden ignoriert;
+INSERT ... ON CONFLICT (quelle, externe_id) DO UPDATE → idempotent).
+PATCH /api/belege/:id (adminOnly) setzt zugeordnet. Bootstrap liefert belege
+nur an die Verwaltung. Frontend: Tabs auf /kosten („Kostenpositionen" /
+„Spark-Receipt-Belege", Admin-only) – Info-Box mit der Webhook-URL, Filter
+Alle/Nicht zugeordnet, Tabelle mit Übernehmen-Button (öffnet KostenDialog
+vorbelegt mit Betrag/Jahr/Notiz „SparkReceipt · Händler (#ID)";
+onGespeichert markiert den Beleg zugeordnet) plus Beleg-Link (datei_url).
+KostenDialog hat neue Props vorbefuellung/onGespeichert/triggerLabel.
+E2E verifiziert (simulierte SR-Payloads): 401 ohne Token, Create, Update
+selbe externe_id ohne Duplikat, Jahr-Ableitung aus Datum, Mieter 403, Admin
+zugeordnet, Testbeleg per SQL entfernt. OFFEN: echtes Feld-Mapping gegen
+ein echtes SparkReceipt-Event prüfen (Event-Log in SR zeigt das Payload –
+ggf. Aliase in belegFelder() nachpflegen); Beleg-PDF-Download aus SR
+nachrüsten, falls die datei_url ein öffentlicher Link ist.
+
**Erledigt 04.10. (v1.15):** Struktur „1 Haus → mehrere Wohneinheiten" in der
Verwaltung verankert + Bugfix Haus-Umbenennung. BUGFIX (wichtig): der FK
`costs_building_fkey` aus v1.14 hatte kein ON UPDATE CASCADE → jede
diff --git a/server/src/db.js b/server/src/db.js
index c68b7fb..32981c0 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -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() {
diff --git a/server/src/index.js b/server/src/index.js
index 627a600..aadfd56 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -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 = []
diff --git a/src/pages/Kosten.tsx b/src/pages/Kosten.tsx
index 8011dc4..98a3535 100644
--- a/src/pages/Kosten.tsx
+++ b/src/pages/Kosten.tsx
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
-import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins } from 'lucide-react'
+import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins, FileScan, Check, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
@@ -28,7 +28,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { useStore, fmtEUR } from '../store/store'
-import type { KostenItem } from '../types'
+import type { Beleg, KostenItem } from '../types'
const KOSTEN_ARTEN = [
'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege',
@@ -40,17 +40,24 @@ const KOSTEN_ARTEN = [
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */
const zahl = (s: string) => Number(s.replace(',', '.'))
-function KostenDialog({ position }: { position?: KostenItem }) {
+function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }: {
+ position?: KostenItem
+ vorbefuellung?: { betrag?: string; jahr?: string; notiz?: string }
+ onGespeichert?: () => void
+ triggerLabel?: string
+}) {
const { addKosten, updateKosten, units, buildings } = useStore()
const bearbeiten = Boolean(position)
const [open, setOpen] = useState(false)
const [kostenart, setKostenart] = useState(position?.kostenart ?? 'Wasser/Abwasser')
- const [jahr, setJahr] = useState(String(position?.jahr ?? new Date().getFullYear()))
- const [betrag, setBetrag] = useState(position ? String(position.betrag).replace('.', ',') : '')
+ const [jahr, setJahr] = useState(String(position?.jahr ?? vorbefuellung?.jahr ?? new Date().getFullYear()))
+ const [betrag, setBetrag] = useState(
+ position ? String(position.betrag).replace('.', ',') : (vorbefuellung?.betrag ?? ''),
+ )
const [einheit, setEinheit] = useState(position?.unitId ?? '__haus') // '__haus' = ganzes Haus
const [haus, setHaus] = useState(position?.building ?? buildings[0]?.name ?? '')
const [umlegbar, setUmlegbar] = useState(position?.umlegbar ?? true)
- const [notiz, setNotiz] = useState(position?.notiz ?? '')
+ const [notiz, setNotiz] = useState(position?.notiz ?? vorbefuellung?.notiz ?? '')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState(null)
@@ -70,6 +77,7 @@ function KostenDialog({ position }: { position?: KostenItem }) {
const err = bearbeiten ? await updateKosten(position!.id, payload) : await addKosten(payload)
setSende(false)
if (err) return setFehler(err)
+ onGespeichert?.()
setOpen(false)
}
@@ -82,7 +90,7 @@ function KostenDialog({ position }: { position?: KostenItem }) {
) : (
)}
@@ -185,8 +193,133 @@ function KostenDialog({ position }: { position?: KostenItem }) {
)
}
+/** Eingegangene SparkReceipt-Belege (nur Verwaltung). */
+function BelegeListe() {
+ const { belege, setBelegZugeordnet } = useStore()
+ const [nurOffene, setNurOffene] = useState(false)
+
+ const zeilen = useMemo(
+ () => (nurOffene ? belege.filter((b) => !b.zugeordnet) : belege),
+ [belege, nurOffene],
+ )
+ const offen = belege.filter((b) => !b.zugeordnet).length
+
+ return (
+
+
+
+ Schnittstelle SparkReceipt
+
+
+ Gescannte Belege aus dem SparkReceipt-Cloudarchiv landen automatisch in dieser Liste.
+ In SparkReceipt unter Integrationen → Webhooks diese URL hinterlegen
+ (Token liegt in der Server-Konfiguration portal.env):
+