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): +

+ + https://mieterportal.hocks.eu/api/belege/webhook?token=… + +
+ +
+ + +
+ +
+ {zeilen.length === 0 ? ( +
+ + {nurOffene ? 'Alle Belege sind zugeordnet.' : 'Noch keine Belege eingegangen.'} +
+ ) : ( + + + + Händler + Datum + Kategorie (SparkReceipt) + Betrag + Status + Aktion + + + + {zeilen.map((b: Beleg) => ( + + + {b.haendler || '–'} + {b.notiz &&
{b.notiz}
} +
+ + {b.datum || '–'} + {b.jahr && · {b.jahr}} + + {b.kategorie || '–'} + + {b.betrag === null ? '–' : `${b.betrag.toLocaleString('de-DE', { minimumFractionDigits: 2 })} ${b.waehrung}`} + + + {b.zugeordnet ? ( + + Zugeordnet + + ) : ( + Offen + )} + + +
+ {!b.zugeordnet && ( + setBelegZugeordnet(b.id, true)} + /> + )} + {b.dateiUrl && ( + + Beleg + + )} +
+
+
+ ))} +
+
+ )} +
+
+ ) +} + export default function Kosten() { const { kosten, role, units, buildings, deleteKosten } = useStore() + const [tab, setTab] = useState<'kosten' | 'belege'>('kosten') const [jahrFilter, setJahrFilter] = useState('alle') const [hausFilter, setHausFilter] = useState('alle') const [artFilter, setArtFilter] = useState<'alle' | 'umlegbar' | 'nicht'>('alle') @@ -230,9 +363,29 @@ export default function Kosten() { : 'Umlegbare und nicht umlegbare Kosten nach Haus und Wohnung – Grundlage für die Nebenkostenabrechnung'}

- {role === 'admin' && } + {role === 'admin' && tab === 'kosten' && } + {role === 'admin' && ( +
+ {([['kosten', 'Kostenpositionen'], ['belege', 'Spark-Receipt-Belege']] as const).map(([wert, label]) => ( + + ))} +
+ )} + + {tab === 'kosten' && ( + <>
@@ -370,6 +523,10 @@ export default function Kosten() { umgelegt; nicht umlegbare Positionen trägt die Eigentümerseite. Erfasst von der Verwaltung, Grundlage der jährlichen Nebenkostenabrechnung.

+ + )} + + {tab === 'belege' && role === 'admin' && }
) } diff --git a/src/store/store.tsx b/src/store/store.tsx index fae9300..8a66fd4 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -33,6 +33,7 @@ import type { ZaehlerArt, Building, KostenItem, + Beleg, } from '../types' import { apiGet, apiPost, apiPatch, apiDelete, ApiFehler } from '../lib/api' import { sha256, DEMO_PASSWORT } from '../lib/auth' @@ -53,6 +54,7 @@ interface Bootstrap { meters: Meter[] buildings: Building[] kosten: KostenItem[] + belege: Beleg[] } const LEER: Bootstrap = { @@ -67,6 +69,7 @@ const LEER: Bootstrap = { meters: [], buildings: [], kosten: [], + belege: [], } interface Store extends Bootstrap { @@ -157,6 +160,8 @@ interface Store extends Bootstrap { k: Partial<{ building: string; unitId: string | null; jahr: number; kostenart: string; betrag: number; umlegbar: boolean; notiz: string }>, ) => Promise deleteKosten: (id: string) => Promise + // ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ── + setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise } const StoreContext = createContext(null) @@ -445,6 +450,16 @@ export function StoreProvider({ children }: { children: ReactNode }) { return fehlerNachricht(e) } }, + // ── Belege (SparkReceipt) ─────────────────────────────── + async setBelegZugeordnet(id, zugeordnet) { + try { + const { beleg } = await apiPatch<{ beleg: Beleg }>(`/api/belege/${id}`, { zugeordnet }) + setDaten((x) => ({ ...x, belege: x.belege.map((b) => (b.id === id ? beleg : b)) })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, }), [daten, bereit, me, role, meineEinheit, ladeAlles]) return {children} diff --git a/src/types/index.ts b/src/types/index.ts index b2534b0..5dfb658 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -133,3 +133,20 @@ export interface KostenItem { notiz: string erfasstAm: string } + +/** Gescannter Beleg aus SparkReceipt (per Webhook importiert). */ +export interface Beleg { + id: string + quelle: string + externeId: string // Dokument-ID in SparkReceipt + haendler: string + betrag: number | null + waehrung: string + datum: string + jahr: number | null + kategorie: string + notiz: string + dateiUrl: string + zugeordnet: boolean // bereits als Kostenposition übernommen + erfasstAm: string +}