diff --git a/src/components/finance/LiquidityHead.jsx b/src/components/finance/LiquidityHead.jsx
new file mode 100644
index 0000000..1c37102
--- /dev/null
+++ b/src/components/finance/LiquidityHead.jsx
@@ -0,0 +1,91 @@
+import { useId, useState } from 'react'
+import { FaChevronDown, FaChevronUp } from 'react-icons/fa6'
+import { Kpi } from '../ui'
+import { euroWhole } from '../../lib/financeFormat'
+import { breakdownRows, lowPointLines } from '../../lib/financeView'
+
+const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
+
+/** Block 1: Kontostand, „Verfügbar nach Rücklagen“ (mit Aufschlüsselung) und „Tiefster Stand in 30 Tagen“. */
+export default function LiquidityHead({ kopf }) {
+ const k = kopf && typeof kopf === 'object' ? kopf : {}
+ const [open, setOpen] = useState(false)
+ const listId = useId()
+
+ const hasBalance = isNum(k.kontostandCents)
+ const shortfall = isNum(k.verfuegbarCents) && k.verfuegbarCents < 0
+ const lows = lowPointLines(k)
+ const anyLowNegative = lows.some((l) => l && l.negative)
+
+ return (
+
+
+
+
+ {shortfall && Rücklagen nicht gedeckt
}
+ {isNum(k.verfuegbarCents) && (
+ <>
+
+ {open && (
+
+ {breakdownRows(k.aufschluesselung, k.verfuegbarCents).map((row) => (
+ -
+ {row.label}
+ {row.text}
+
+ ))}
+
+ )}
+ >
+ )}
+ {!isNum(k.verfuegbarCents) && Ohne Kontostand nicht berechenbar
}
+ >
+ }
+ />
+
+
+ {lows.map(
+ (low) =>
+ low && (
+
+ {low.label}:
+ {low.amountText}
+
+ am {low.dateText}
+ {low.negative && · im Minus}
+
+
+ )
+ )}
+
+ ) : (
+ '–'
+ )
+ }
+ sub={lows.some(Boolean) ? '„erwartet“ zählt offene Rechnungen und Abos dazu' : 'Ohne Kontostand nicht berechenbar'}
+ />
+
+ )
+}
diff --git a/src/components/finance/StatusLine.jsx b/src/components/finance/StatusLine.jsx
new file mode 100644
index 0000000..c83d7c0
--- /dev/null
+++ b/src/components/finance/StatusLine.jsx
@@ -0,0 +1,57 @@
+import { useEffect, useRef, useState } from 'react'
+import { FaRotate, FaTriangleExclamation } from 'react-icons/fa6'
+import { Button } from '../ui'
+import { statusItems } from '../../lib/financeView'
+
+/**
+ * Block 0: Stand von Bank und Stripe in einer Zeile und der Knopf „Aktualisieren“.
+ * onRefresh() ruft den Bankabruf und lädt die Übersicht neu; wirft er (z. B. 429 „Bitte in N Minuten erneut
+ * versuchen.“), erscheint die Meldung des Servers als Hinweis unter der Zeile.
+ */
+export default function StatusLine({ stand, onRefresh, refreshing = false }) {
+ const [hint, setHint] = useState(null) // { text, limited }
+ const aliveRef = useRef(true)
+ useEffect(() => {
+ aliveRef.current = true
+ return () => {
+ aliveRef.current = false
+ }
+ }, [])
+
+ const items = statusItems(stand)
+
+ async function handleRefresh() {
+ setHint(null)
+ try {
+ await onRefresh()
+ } catch (err) {
+ if (aliveRef.current) {
+ setHint({ text: err?.message || 'Der Abruf ist fehlgeschlagen.', limited: err?.status === 429 })
+ }
+ }
+ }
+
+ return (
+
+
+
+ {items.map((item) => (
+
+ {!item.ok && }
+ {item.text}
+
+ ))}
+
+
+
+ {hint && (
+
+ {hint.text}
+
+ )}
+
+ )
+}
diff --git a/src/components/finance/TodoList.jsx b/src/components/finance/TodoList.jsx
new file mode 100644
index 0000000..f1533e4
--- /dev/null
+++ b/src/components/finance/TodoList.jsx
@@ -0,0 +1,85 @@
+import { useEffect, useRef, useState } from 'react'
+import { Badge, Button, Card, EmptyState } from '../ui'
+import { TODO_LIMIT, todoButtonLabel, todoKey, urgencyOf, visibleTodos } from '../../lib/financeView'
+
+/**
+ * Block 2: „Zu erledigen“, höchstens 7 Zeilen, danach „Alle N anzeigen“. Je Zeile Dringlichkeit (Text), Titel,
+ * Text und genau ein Knopf. onAction(aktion, aufgabe) darf ein Promise liefern; wirft es, steht die Meldung
+ * unter der Zeile. Solange eine Aktion läuft, sind alle Knöpfe gesperrt (kein Doppelklick).
+ */
+export default function TodoList({ aufgaben, onAction }) {
+ const [expanded, setExpanded] = useState(false)
+ const [running, setRunning] = useState(null) // Schlüssel der laufenden Aufgabe
+ const [failure, setFailure] = useState(null) // { key, message }
+ const aliveRef = useRef(true)
+ useEffect(() => {
+ aliveRef.current = true
+ return () => {
+ aliveRef.current = false
+ }
+ }, [])
+
+ const { shown, total } = visibleTodos(aufgaben, expanded)
+
+ async function run(todo, key) {
+ if (running) return
+ setFailure(null)
+ setRunning(key)
+ try {
+ await onAction(todo.aktion, todo)
+ } catch (err) {
+ if (aliveRef.current) setFailure({ key, message: err?.message || 'Die Aktion ist fehlgeschlagen.' })
+ } finally {
+ if (aliveRef.current) setRunning(null)
+ }
+ }
+
+ return (
+ 0 ? {total} offen : null}
+ className="fin-todo-card"
+ bodyStyle={{ padding: 0 }}
+ >
+ {total === 0 ? (
+
+ ) : (
+ <>
+
+ {shown.map((todo, index) => {
+ const key = todoKey(todo, index)
+ const urgency = urgencyOf(todo)
+ const label = todoButtonLabel(todo)
+ return (
+ -
+ {urgency.label}
+
+
{todo.titel}
+ {todo.text &&
{todo.text}
}
+ {failure?.key === key && (
+
+ {failure.message}
+
+ )}
+
+ {label && (
+
+ )}
+
+ )
+ })}
+
+ {total > TODO_LIMIT && (
+
+
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/src/hooks/useFinanceOverview.js b/src/hooks/useFinanceOverview.js
new file mode 100644
index 0000000..5ab73bf
--- /dev/null
+++ b/src/hooks/useFinanceOverview.js
@@ -0,0 +1,43 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { getOverview } from '../lib/financeApi'
+
+const FALLBACK_ERROR = 'Die Übersicht konnte nicht geladen werden.'
+
+/**
+ * Lädt die Finanzübersicht (GET /uebersicht).
+ * data bleibt beim erneuten Laden stehen, damit die Seite nicht aufblitzt; error ist die Meldung des letzten
+ * fehlgeschlagenen Versuchs (leer, solange alles in Ordnung ist). reload() liefert ein Promise, das nie wirft.
+ */
+export function useFinanceOverview() {
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const aliveRef = useRef(true)
+ const seqRef = useRef(0)
+
+ const reload = useCallback(async () => {
+ // Nur die jüngste Anfrage zählt: eine langsame ältere darf neuere Daten nicht überschreiben
+ const mine = ++seqRef.current
+ const current = () => aliveRef.current && mine === seqRef.current
+ setLoading(true)
+ setError('')
+ try {
+ const next = await getOverview()
+ if (current()) setData(next && typeof next === 'object' ? next : {})
+ } catch (err) {
+ if (current()) setError(err?.message || FALLBACK_ERROR)
+ } finally {
+ if (current()) setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ aliveRef.current = true
+ reload()
+ return () => {
+ aliveRef.current = false
+ }
+ }, [reload])
+
+ return { data, loading, error, reload }
+}
diff --git a/src/lib/financeFormat.js b/src/lib/financeFormat.js
index 52f50f5..7d32d99 100644
--- a/src/lib/financeFormat.js
+++ b/src/lib/financeFormat.js
@@ -84,6 +84,15 @@ export function sinceLabel(iso, now = Date.now()) {
return days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`
}
+/** Berliner Kalendertag ('YYYY-MM-DD') eines ISO-Zeitpunkts, Date oder Millisekundenwerts; ungueltig -> null. */
+export function ymdBerlin(value) {
+ const t =
+ value instanceof Date ? value.getTime() : typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : NaN
+ if (!Number.isFinite(t)) return null
+ const parts = Object.fromEntries(berlinDate.formatToParts(new Date(t)).map((p) => [p.type, p.value]))
+ return `${parts.year}-${parts.month}-${parts.day}`
+}
+
// Ganze Euro mit Tausenderpunkten (1.234.567) oder ohne Punkte, optional ,1-2 Nachkommastellen.
// Eine fuehrende 0 ist nur ohne Tausenderpunkte erlaubt, '12.50' (englisch) ist absichtlich ungueltig.
const AMOUNT = /^([1-9][0-9]{0,2}(?:\.[0-9]{3})+|[0-9]+)(?:,([0-9]{1,2}))?$/
@@ -140,6 +149,38 @@ export const AGE_GROUP_LABEL = {
'ueber-60': 'Über 60 Tage',
}
+export const INTERVAL_LABEL = {
+ monat: 'monatlich',
+ quartal: 'vierteljährlich',
+ jahr: 'jährlich',
+}
+
+const INTERVAL_MONTHS = { monat: 1, quartal: 3, jahr: 12 }
+
+function daysInMonth(year, month) {
+ if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28
+ return [4, 6, 9, 11].includes(month) ? 30 : 31
+}
+
+/**
+ * Naechster Termin einer Folge: '2026-01-31' + 'monat' -> '2026-02-28' (Monatsende wird begrenzt, nie in den
+ * Folgemonat uebergelaufen). Rhythmen: monat, quartal, jahr. Ungueltiges Datum oder unbekannter Rhythmus -> null.
+ */
+export function nextIntervalDate(ymd, interval) {
+ const months = Object.prototype.hasOwnProperty.call(INTERVAL_MONTHS, interval) ? INTERVAL_MONTHS[interval] : 0
+ const m = typeof ymd === 'string' ? DATE_ONLY.exec(ymd.trim()) : null
+ if (!months || !m) return null
+ const year = Number(m[1])
+ const month = Number(m[2])
+ const day = Number(m[3])
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null
+ const total = year * 12 + (month - 1) + months
+ const nextYear = Math.floor(total / 12)
+ const nextMonth = (total % 12) + 1
+ const nextDay = Math.min(day, daysInMonth(nextYear, nextMonth))
+ return `${String(nextYear).padStart(4, '0')}-${String(nextMonth).padStart(2, '0')}-${String(nextDay).padStart(2, '0')}`
+}
+
/** Seite einer Liste (1-basiert); die Seitenzahl wird auf 1..pages begrenzt. */
export function pageOf(list, page, size = 50) {
const items = Array.isArray(list) ? list : []
diff --git a/src/lib/financeView.js b/src/lib/financeView.js
new file mode 100644
index 0000000..08b0e73
--- /dev/null
+++ b/src/lib/financeView.js
@@ -0,0 +1,218 @@
+// Reine Darstellungs-Helfer fuer Statuszeile, Liquiditaetskopf und Aufgabenliste der Finanzen-Seite
+// (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
+import {
+ INTERVAL_LABEL,
+ dateDe,
+ euro,
+ euroWhole,
+ nextIntervalDate,
+ signedEuro,
+ sinceLabel,
+ ymdBerlin,
+ URGENCY,
+} from './financeFormat'
+
+const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
+const text = (v) => (typeof v === 'string' ? v.trim() : '')
+
+/** Id des Rechnungsblocks (Task 5): Ziel für „Zu den Rechnungen“. */
+export const INVOICES_ANCHOR = 'finanzen-rechnungen'
+/** Wie viele Aufgaben ohne „Alle anzeigen“ sichtbar sind. */
+export const TODO_LIMIT = 7
+
+// --- Statuszeile (Block 0) ---------------------------------------------------------------------------------
+
+/**
+ * Bank-Teil der Statuszeile: { ok, text }. ok=false zeigt eine Warnung (Text steht immer dabei).
+ * Normal: „Bank: Stand vor 3 Std. · jüngste Buchung 29.09.2026 · Zugang bis 29.03.2027“.
+ */
+export function bankStatus(bank, now = Date.now()) {
+ if (!bank || typeof bank !== 'object') return { ok: false, text: 'Bank: nicht verbunden' }
+
+ const until = dateDe(bank.validUntil)
+ const untilKnown = until !== '–'
+ const validDay = ymdBerlin(bank.validUntil)
+ const today = ymdBerlin(now)
+ const expired = bank.status === 'abgelaufen' || (validDay !== null && today !== null && validDay < today)
+ if (expired) return { ok: false, text: `Bank: Zugang abgelaufen${untilKnown ? ` am ${until}` : ''}` }
+
+ const parts = []
+ if (bank.lastSyncOk === false || bank.status === 'fehler') {
+ const error = text(bank.lastError)
+ parts.push(`Abruf fehlgeschlagen${error ? `: ${error}` : ''}`)
+ if (untilKnown) parts.push(`Zugang bis ${until}`)
+ return { ok: false, text: `Bank: ${parts.join(' · ')}` }
+ }
+
+ parts.push(text(bank.lastSyncAt) ? `Stand ${sinceLabel(bank.lastSyncAt, now)}` : 'noch kein Abruf')
+ if (text(bank.latestBookingDate)) parts.push(`jüngste Buchung ${dateDe(bank.latestBookingDate)}`)
+ if (untilKnown) parts.push(`Zugang bis ${until}`)
+ return { ok: true, text: `Bank: ${parts.join(' · ')}` }
+}
+
+/** Stripe-Teil der Statuszeile: { ok, text }; bei Ausfall mit dem Text des Servers. */
+export function stripeStatus(stripe, now = Date.now()) {
+ if (!stripe || typeof stripe !== 'object') return { ok: true, text: 'Stripe: Stand unbekannt' }
+ if (stripe.ok === false) {
+ return {
+ ok: false,
+ text: `Stripe: Abruf fehlgeschlagen: ${text(stripe.fehler) || 'Stripe ist gerade nicht erreichbar.'}`,
+ }
+ }
+ const since = text(stripe.abgerufenUm) ? sinceLabel(stripe.abgerufenUm, now) : '–'
+ return { ok: true, text: `Stripe: Stand ${since === '–' ? 'unbekannt' : since}` }
+}
+
+/** [{ key, ok, text }] für Bank und Stripe. */
+export function statusItems(stand, now = Date.now()) {
+ const s = stand && typeof stand === 'object' ? stand : {}
+ return [
+ { key: 'bank', ...bankStatus(s.bank, now) },
+ { key: 'stripe', ...stripeStatus(s.stripe, now) },
+ ]
+}
+
+// --- Liquiditätskopf (Block 1) -----------------------------------------------------------------------------
+
+/**
+ * Aufschlüsselung „Verfügbar nach Rücklagen“ als Zeilen { key, label, text, total }:
+ * Kontostand − Steuer-Rücklage − Fixkosten 30 Tage − Mindestpuffer = Verfügbar (Beträge auf den Cent genau).
+ */
+export function breakdownRows(aufschluesselung, verfuegbarCents) {
+ const a = aufschluesselung && typeof aufschluesselung === 'object' ? aufschluesselung : {}
+ const minus = (cents) => (isNum(cents) ? signedEuro(-cents) : '–')
+ return [
+ { key: 'kontostand', label: 'Kontostand (gebucht)', text: euro(a.kontostandCents), total: false },
+ { key: 'steuer', label: 'Steuer-Rücklage', text: minus(a.steuerRuecklageCents), total: false },
+ { key: 'fixkosten', label: 'Fixkosten der nächsten 30 Tage', text: minus(a.fixkosten30Cents), total: false },
+ { key: 'puffer', label: 'Mindestpuffer', text: minus(a.mindestpufferCents), total: false },
+ { key: 'verfuegbar', label: 'Verfügbar nach Rücklagen', text: euro(verfuegbarCents), total: true },
+ ]
+}
+
+/**
+ * Zwei Zeilen „Tiefster Stand in 30 Tagen“: sicher und erwartet, je { key, label, amountText, dateText, negative };
+ * ein Eintrag ist null, wenn der Server keinen Wert liefert (kein Kontostand).
+ */
+export function lowPointLines(kopf) {
+ const k = kopf && typeof kopf === 'object' ? kopf : {}
+ const line = (key, label, point) =>
+ point && isNum(point.amountCents)
+ ? { key, label, amountText: euroWhole(point.amountCents), dateText: dateDe(point.date), negative: point.amountCents < 0 }
+ : null
+ return [line('sicher', 'sicher', k.tiefsterSicher), line('erwartet', 'erwartet', k.tiefsterErwartet)]
+}
+
+// --- Zu erledigen (Block 2) --------------------------------------------------------------------------------
+
+/** Nur Aufgaben (Objekte) in Serverreihenfolge; bei eingeklappter Liste höchstens `max` Stück. */
+export function visibleTodos(aufgaben, expanded = false, max = TODO_LIMIT) {
+ const items = Array.isArray(aufgaben) ? aufgaben.filter((t) => t && typeof t === 'object') : []
+ if (expanded || items.length <= max) return { shown: items, total: items.length, hidden: 0 }
+ return { shown: items.slice(0, max), total: items.length, hidden: items.length - max }
+}
+
+export function urgencyOf(todo) {
+ return URGENCY[todo?.dringlichkeit] || URGENCY.info
+}
+
+/** Stabiler Schlüssel einer Aufgabe (für React und den Zustand „läuft gerade“). */
+export function todoKey(todo, index = 0) {
+ const a = todo?.aktion || {}
+ const ref = a.transactionId || a.invoiceId || a.customerId || a.vorschlag?.key || ''
+ return [index, todo?.art || '', todo?.datum || '', ref].join('|')
+}
+
+/** http(s)-Adresse oder ''; schützt vor `javascript:` und Ähnlichem aus Serverdaten. */
+export function safeUrl(value) {
+ if (typeof value !== 'string' || !value.trim()) return ''
+ try {
+ const url = new URL(value.trim())
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : ''
+ } catch {
+ return ''
+ }
+}
+
+/** Beschriftung des einen Knopfs einer Aufgabe; '' (kein Knopf) bei unbekannter Aktion. */
+export function todoButtonLabel(todo) {
+ const a = todo?.aktion
+ switch (a?.typ) {
+ case 'bank-verbinden':
+ return 'Bank verbinden'
+ case 'bank-aktualisieren':
+ return 'Aktualisieren'
+ case 'rechnung-oeffnen':
+ return safeUrl(a.hostedUrl) ? 'Zahlungsseite öffnen' : 'Zu den Rechnungen'
+ case 'zuordnen':
+ return 'Zuordnen'
+ case 'kunde-oeffnen':
+ return 'Kunde öffnen'
+ case 'fixkosten-uebernehmen':
+ return 'Übernehmen'
+ case 'einstellungen':
+ return 'Einstellungen'
+ default:
+ return ''
+ }
+}
+
+/**
+ * Fixkosten-Vorschlag des Servers ({ key, name, amountCents, interval, lastDate }) -> Anfrage für createFixedCost
+ * samt Rückfrage-Text; null, wenn der Vorschlag unvollständig ist. nextDate = lastDate + ein Rhythmus.
+ */
+export function fixedCostFromSuggestion(vorschlag) {
+ const v = vorschlag && typeof vorschlag === 'object' ? vorschlag : {}
+ const name = text(v.name)
+ const nextDate = nextIntervalDate(v.lastDate, v.interval)
+ if (!name || !Number.isSafeInteger(v.amountCents) || v.amountCents <= 0 || !nextDate) return null
+ return {
+ payload: {
+ name,
+ amountCents: v.amountCents,
+ interval: v.interval,
+ nextDate,
+ counterpartyMatch: typeof v.key === 'string' ? v.key : '',
+ },
+ confirmText: `Als Fixkosten übernehmen: ${name}, ${euro(v.amountCents)} ${INTERVAL_LABEL[v.interval]}?`,
+ }
+}
+
+const planError = (message) => ({ kind: 'error', message })
+
+/**
+ * Was ein Klick auf den Aufgaben-Knopf bewirkt (die Seite führt es aus):
+ * bank-connect | bank-refresh | open-url {url} | scroll {id} | assign {transactionId} | navigate {to} |
+ * fixed-cost {payload, confirmText} | error {message}.
+ */
+export function planTodoAction(aktion) {
+ const a = aktion && typeof aktion === 'object' ? aktion : {}
+ switch (a.typ) {
+ case 'bank-verbinden':
+ return { kind: 'bank-connect' }
+ case 'bank-aktualisieren':
+ return { kind: 'bank-refresh' }
+ case 'rechnung-oeffnen': {
+ const url = safeUrl(a.hostedUrl)
+ return url ? { kind: 'open-url', url } : { kind: 'scroll', id: INVOICES_ANCHOR }
+ }
+ case 'zuordnen':
+ return text(a.transactionId)
+ ? { kind: 'assign', transactionId: a.transactionId }
+ : planError('Die Buchung ist nicht bekannt.')
+ case 'kunde-oeffnen':
+ return text(a.customerId)
+ ? { kind: 'navigate', to: `/customers/${encodeURIComponent(a.customerId)}` }
+ : planError('Zu diesem Eintrag ist kein Kunde hinterlegt.')
+ case 'einstellungen':
+ return { kind: 'navigate', to: '/finance/einstellungen' }
+ case 'fixkosten-uebernehmen': {
+ const suggestion = fixedCostFromSuggestion(a.vorschlag)
+ return suggestion
+ ? { kind: 'fixed-cost', ...suggestion }
+ : planError('Der Vorschlag ist unvollständig und kann nicht übernommen werden.')
+ }
+ default:
+ return planError('Diese Aktion ist nicht bekannt.')
+ }
+}
diff --git a/src/pages/FinancePage.jsx b/src/pages/FinancePage.jsx
index 35b564e..adf77c8 100644
--- a/src/pages/FinancePage.jsx
+++ b/src/pages/FinancePage.jsx
@@ -1,127 +1,113 @@
-import { useState, useEffect, useCallback, useMemo } from 'react'
-import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate } from 'react-icons/fa6'
-import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
-import { useFinanceSummary } from '../hooks/useFinanceSummary'
-import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
+import { useCallback, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { FaSpinner } from 'react-icons/fa6'
+import { PageHeader, Card, Button } from '../components/ui'
+import StatusLine from '../components/finance/StatusLine'
+import LiquidityHead from '../components/finance/LiquidityHead'
+import TodoList from '../components/finance/TodoList'
+import { useFinanceOverview } from '../hooks/useFinanceOverview'
+import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
+import { planTodoAction, safeUrl } from '../lib/financeView'
export default function FinancePage() {
- const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
- const [invoices, setInvoices] = useState([])
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(null)
- const [filter, setFilter] = useState('all') // all | open | overdue | paid
- const [search, setSearch] = useState('')
- const [openingId, setOpeningId] = useState(null)
+ const navigate = useNavigate()
+ const { data, loading, error, reload } = useFinanceOverview()
+ const [refreshing, setRefreshing] = useState(false)
+ // Buchung, die gerade zugeordnet werden soll; den Dialog dazu zeigt Block 7 (Task 5)
+ const [assignTxId, setAssignTxId] = useState(null)
+ const refreshLock = useRef(false)
- const load = useCallback(async () => {
- setLoading(true)
- setError(null)
+ // Bankabruf anstoßen, danach die Übersicht neu laden. Fehler (z. B. 429) gehen an den Aufrufer.
+ // Ein Abruf, der bei der Bank scheitert, antwortet mit ok:false; das steht danach in der Statuszeile.
+ const refresh = useCallback(async () => {
+ if (refreshLock.current) return
+ refreshLock.current = true
+ setRefreshing(true)
try {
- const data = await integrationsApi.listInvoices(undefined, 400)
- setInvoices(data.invoices || [])
- } catch (err) {
- setError(err.message)
+ await refreshBank()
+ await reload()
} finally {
- setLoading(false)
+ refreshLock.current = false
+ setRefreshing(false)
}
- }, [])
+ }, [reload])
- useEffect(() => { load() }, [load])
-
- const today = new Date().toISOString().slice(0, 10)
- const filtered = useMemo(() => {
- return invoices.filter((inv) => {
- const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
- if (filter === 'open' && !(Number(inv.balance) > 0)) return false
- if (filter === 'overdue' && !overdue) return false
- // InvoiceNinja liefert status_id als Text ("4" = bezahlt)
- if (filter === 'paid' && String(inv.status_id) !== '4') return false
- if (search) {
- const s = search.toLowerCase()
- if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
+ const handleAction = useCallback(
+ async (aktion) => {
+ const plan = planTodoAction(aktion)
+ switch (plan.kind) {
+ case 'bank-connect': {
+ const { url } = await startBankAuth()
+ const target = safeUrl(url)
+ if (!target) throw new Error('Die Bank hat keine Weiterleitung geliefert.')
+ window.location.assign(target)
+ return
+ }
+ case 'bank-refresh':
+ return refresh()
+ case 'open-url':
+ window.open(plan.url, '_blank', 'noopener,noreferrer')
+ return
+ case 'scroll':
+ // Der Rechnungsblock (Task 5) trägt diese Id; ohne ihn passiert nichts. Erst nach dem Neuzeichnen
+ // starten: ein Scroll mitten im Klick wird sonst vom Neuzeichnen der Aufgabenzeile abgebrochen.
+ requestAnimationFrame(() => {
+ document.getElementById(plan.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ })
+ return
+ case 'assign':
+ setAssignTxId(plan.transactionId)
+ return
+ case 'navigate':
+ navigate(plan.to)
+ return
+ case 'fixed-cost':
+ if (!window.confirm(plan.confirmText)) return
+ await createFixedCost(plan.payload)
+ await reload()
+ return
+ default:
+ throw new Error(plan.message)
}
- return true
- })
- }, [invoices, filter, search, today])
+ },
+ [navigate, refresh, reload]
+ )
- const openPdf = async (id) => {
- setOpeningId(id)
- try { await integrationsApi.openInvoicePdf(id) }
- catch (err) { alert('PDF-Fehler: ' + err.message) }
- finally { setOpeningId(null) }
+ let body
+ if (!data && loading) {
+ body = (
+
+
+
Finanzen werden geladen …
+
+ )
+ } else if (!data) {
+ body = (
+
+ {error || 'Die Übersicht konnte nicht geladen werden.'}
+
+
+ )
+ } else {
+ body = (
+
+ {error && (
+
+ Die Übersicht konnte nicht neu geladen werden: {error}{' '}
+
+
+ )}
+
+
+
+
+ )
}
return (
-
-
-
- >
- }
- />
-
-
-
-
-
-
-
-
-
-
-
- {[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Überfällig'], ['paid', 'Bezahlt']].map(([id, label]) => (
-
- ))}
-
-
setSearch(e.target.value)} style={{ maxWidth: 280 }} />
-
-
- {loading ? (
-
- ) : error ? (
-
{error}
- ) : filtered.length === 0 ? (
-
- ) : (
-
-
-
- | Nr. | Kunde | Datum | Fällig | Betrag | Offen | Status | |
-
-
- {filtered.map((inv) => {
- const st = INVOICE_STATUS[inv.status_id] || { label: '–', color: 'var(--text-muted)' }
- const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
- return (
-
- | {inv.number || inv.id?.slice(-6)} |
- {inv.client_name || '-'} |
- {inv.date || '-'} |
- {inv.due_date || '-'} |
- {formatMoney(inv.amount)} |
- 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)} |
- {st.label}{overdue ? ' (überfällig)' : ''} |
-
-
- |
-
- )
- })}
-
-
-
- )}
-
-
+
+ {body}
)
}
diff --git a/src/styles/global.css b/src/styles/global.css
index 2842c2b..5a91e05 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1147,3 +1147,61 @@ textarea.form-control {
.legal-form-row { grid-template-columns: 1fr; }
.legal-contracts { display: block; overflow-x: auto; }
}
+
+/* --- Finanzen --- */
+.fin-amount { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.fin-stack { display: flex; flex-direction: column; gap: var(--sp-5); }
+.fin-stack .kpi-grid { margin-bottom: 0; }
+.fin-spin { animation: spin 1s linear infinite; }
+.fin-hint { margin: 0; font-size: 13px; color: var(--text-muted); }
+.fin-hint-warn { color: var(--warn); }
+.fin-flag { color: var(--danger); font-weight: 700; }
+.fin-link {
+ background: none; border: 0; padding: 0; font: inherit; cursor: pointer;
+ color: var(--accent); display: inline-flex; align-items: center; gap: 6px;
+ text-decoration: underline; text-underline-offset: 2px;
+}
+.fin-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
+
+/* Block 0: Statuszeile */
+.fin-status-wrap { display: flex; flex-direction: column; gap: var(--sp-2); }
+.fin-status {
+ display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--sp-3);
+ padding: 10px 14px; font-size: 13px; color: var(--text-muted);
+ background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md);
+}
+.fin-status-items { display: flex; flex-wrap: wrap; gap: 4px var(--sp-4); min-width: 0; }
+.fin-status-item { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
+.fin-status-warn { color: var(--warn); font-weight: 600; }
+
+/* Block 1: Liquiditaetskopf */
+.fin-head .kpi-value { font-variant-numeric: tabular-nums; }
+.fin-breakdown { list-style: none; margin: var(--sp-2) 0 0; padding: 0; display: grid; gap: 4px; color: var(--text-muted); }
+.fin-breakdown li { display: flex; justify-content: space-between; gap: var(--sp-3); }
+.fin-breakdown .fin-amount { color: var(--text); }
+.fin-breakdown-total { border-top: 1px solid var(--border-strong); padding-top: 4px; font-weight: 700; color: var(--text); }
+.fin-lows { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 6px 10px; align-items: baseline; font-size: 14px; font-weight: 600; }
+.fin-low { display: contents; }
+.fin-low-label { color: var(--text-muted); }
+.fin-low .fin-amount { font-size: 20px; font-weight: 800; }
+.fin-low-when { color: var(--text-muted); font-size: 13px; font-weight: 500; }
+
+/* Block 2: Zu erledigen */
+.fin-todo-card { overflow: hidden; }
+.fin-todos { list-style: none; margin: 0; padding: 0; }
+.fin-todo {
+ display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: var(--sp-3); align-items: start;
+ padding: 12px var(--sp-4); border-bottom: 1px solid var(--border); border-left: 3px solid var(--border);
+}
+.fin-todo:last-child { border-bottom: none; }
+.fin-todo-hoch { border-left-color: var(--danger); }
+.fin-todo-mittel { border-left-color: var(--warn); }
+.fin-todo .badge { justify-self: start; }
+.fin-todo-title { font-weight: 700; }
+.fin-todo-text { font-size: 13px; margin-top: 2px; overflow-wrap: anywhere; }
+.fin-todo .fin-hint { margin-top: 6px; }
+.fin-todos-more { padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
+@media (max-width: 720px) {
+ .fin-todo { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); }
+ .fin-todo .ui-btn { justify-self: start; }
+}
diff --git a/tests/unit/financeFormat.test.mjs b/tests/unit/financeFormat.test.mjs
index e88edcf..71855ab 100644
--- a/tests/unit/financeFormat.test.mjs
+++ b/tests/unit/financeFormat.test.mjs
@@ -15,6 +15,9 @@ const {
AGE_GROUP_LABEL,
pageOf,
canInvoice,
+ nextIntervalDate,
+ ymdBerlin,
+ INTERVAL_LABEL,
} = await loadModule('src/lib/financeFormat.js')
const MINUS = '−'
@@ -245,3 +248,53 @@ test('canInvoice: kein Kunde', () => {
assert.equal(canInvoice(undefined).ok, false)
assert.equal(typeof canInvoice(null).reason, 'string')
})
+
+test('nextIntervalDate: ein Rhythmus nach dem Datum', () => {
+ assert.equal(nextIntervalDate('2026-09-27', 'monat'), '2026-10-27')
+ assert.equal(nextIntervalDate('2026-09-27', 'quartal'), '2026-12-27')
+ assert.equal(nextIntervalDate('2026-09-27', 'jahr'), '2027-09-27')
+ assert.equal(nextIntervalDate('2026-12-15', 'monat'), '2027-01-15')
+ assert.equal(nextIntervalDate('2026-11-15', 'quartal'), '2027-02-15')
+})
+
+test('nextIntervalDate: Monatsende wird begrenzt, nicht in den Folgemonat übergelaufen', () => {
+ assert.equal(nextIntervalDate('2026-01-31', 'monat'), '2026-02-28')
+ assert.equal(nextIntervalDate('2024-01-31', 'monat'), '2024-02-29')
+ assert.equal(nextIntervalDate('2026-03-31', 'monat'), '2026-04-30')
+ assert.equal(nextIntervalDate('2026-11-30', 'quartal'), '2027-02-28')
+ assert.equal(nextIntervalDate('2026-08-31', 'quartal'), '2026-11-30')
+ assert.equal(nextIntervalDate('2024-02-29', 'jahr'), '2025-02-28')
+ assert.equal(nextIntervalDate('2023-02-28', 'jahr'), '2024-02-28')
+ assert.equal(nextIntervalDate('1900-01-31', 'monat'), '1900-02-28') // 1900 ist kein Schaltjahr
+ assert.equal(nextIntervalDate('2000-01-31', 'monat'), '2000-02-29')
+})
+
+test('nextIntervalDate: ungültige Eingaben ergeben null', () => {
+ assert.equal(nextIntervalDate('2026-02-30', 'monat'), null)
+ assert.equal(nextIntervalDate('2026-13-01', 'monat'), null)
+ assert.equal(nextIntervalDate('2026-00-10', 'monat'), null)
+ assert.equal(nextIntervalDate('2026-04-31', 'monat'), null)
+ assert.equal(nextIntervalDate('27.09.2026', 'monat'), null)
+ assert.equal(nextIntervalDate('', 'monat'), null)
+ assert.equal(nextIntervalDate(null, 'monat'), null)
+ assert.equal(nextIntervalDate(undefined, 'monat'), null)
+ assert.equal(nextIntervalDate('2026-09-27', 'woche'), null)
+ assert.equal(nextIntervalDate('2026-09-27', undefined), null)
+ assert.equal(nextIntervalDate('2026-09-27', 'constructor'), null)
+})
+
+test('INTERVAL_LABEL: deutsche Rhythmus-Wörter wie im Portal', () => {
+ assert.deepEqual(INTERVAL_LABEL, { monat: 'monatlich', quartal: 'vierteljährlich', jahr: 'jährlich' })
+})
+
+test('ymdBerlin: Berliner Kalendertag eines Zeitpunkts', () => {
+ assert.equal(ymdBerlin('2026-10-01T10:55:21.691Z'), '2026-10-01')
+ assert.equal(ymdBerlin('2026-07-01T23:30:00.000Z'), '2026-07-02') // Sommerzeit: schon der nächste Tag
+ assert.equal(ymdBerlin('2099-03-29T00:00:00.000Z'), '2099-03-29')
+ assert.equal(ymdBerlin(new Date('2026-12-31T23:30:00Z')), '2027-01-01')
+ assert.equal(ymdBerlin(Date.UTC(2026, 9, 1, 12)), '2026-10-01')
+ assert.equal(ymdBerlin(''), null)
+ assert.equal(ymdBerlin('kein Datum'), null)
+ assert.equal(ymdBerlin(null), null)
+ assert.equal(ymdBerlin(NaN), null)
+})
diff --git a/tests/unit/financeView.test.mjs b/tests/unit/financeView.test.mjs
new file mode 100644
index 0000000..609bfd7
--- /dev/null
+++ b/tests/unit/financeView.test.mjs
@@ -0,0 +1,258 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const {
+ INVOICES_ANCHOR,
+ TODO_LIMIT,
+ bankStatus,
+ stripeStatus,
+ statusItems,
+ breakdownRows,
+ lowPointLines,
+ visibleTodos,
+ urgencyOf,
+ todoKey,
+ safeUrl,
+ todoButtonLabel,
+ fixedCostFromSuggestion,
+ planTodoAction,
+} = await loadModule('src/lib/financeView.js')
+
+const MINUS = '−'
+const NOW = Date.parse('2026-10-01T13:55:21.691Z') // 15:55 Berliner Zeit
+
+const bank = (extra = {}) => ({
+ status: 'aktiv',
+ ibanMasked: 'DE00 •••• 9313',
+ validUntil: '2027-03-29T00:00:00.000Z',
+ lastSyncAt: '2026-10-01T10:55:21.691Z',
+ lastSyncOk: true,
+ lastError: '',
+ balanceBookedCents: 10893,
+ balanceAvailableCents: 9999,
+ balanceDate: '2026-10-01',
+ latestBookingDate: '2026-09-29',
+ ...extra,
+})
+
+// --- Statuszeile ---------------------------------------------------------------------------------------------
+
+test('bankStatus: Stand, jüngste Buchung und Zugang bis (Spec Block 0)', () => {
+ assert.deepEqual(bankStatus(bank(), NOW), {
+ ok: true,
+ text: 'Bank: Stand vor 3 Std. · jüngste Buchung 29.09.2026 · Zugang bis 29.03.2027',
+ })
+})
+
+test('bankStatus: ohne Verbindung', () => {
+ for (const none of [null, undefined, 'x']) {
+ assert.deepEqual(bankStatus(none, NOW), { ok: false, text: 'Bank: nicht verbunden' })
+ }
+})
+
+test('bankStatus: Abruf fehlgeschlagen zeigt den Fehler und weiter den Zugang', () => {
+ const r = bankStatus(bank({ lastSyncOk: false, lastError: 'Bank antwortet nicht' }), NOW)
+ assert.equal(r.ok, false)
+ assert.equal(r.text, 'Bank: Abruf fehlgeschlagen: Bank antwortet nicht · Zugang bis 29.03.2027')
+ assert.equal(bankStatus(bank({ lastSyncOk: false, lastError: '' }), NOW).text, 'Bank: Abruf fehlgeschlagen · Zugang bis 29.03.2027')
+ assert.equal(bankStatus(bank({ status: 'fehler', lastError: 'x' }), NOW).ok, false)
+})
+
+test('bankStatus: abgelaufener Zugang (Status oder Datum)', () => {
+ assert.deepEqual(bankStatus(bank({ status: 'abgelaufen' }), NOW), { ok: false, text: 'Bank: Zugang abgelaufen am 29.03.2027' })
+ const past = bankStatus(bank({ validUntil: '2026-09-30T00:00:00.000Z' }), NOW)
+ assert.deepEqual(past, { ok: false, text: 'Bank: Zugang abgelaufen am 30.09.2026' })
+ // am letzten Tag ist der Zugang noch gültig
+ assert.equal(bankStatus(bank({ validUntil: '2026-10-01T00:00:00.000Z' }), NOW).ok, true)
+})
+
+test('bankStatus: noch kein Abruf, keine Buchung, kein Datum', () => {
+ const r = bankStatus(bank({ lastSyncAt: null, latestBookingDate: null, validUntil: null }), NOW)
+ assert.deepEqual(r, { ok: true, text: 'Bank: noch kein Abruf' })
+})
+
+test('stripeStatus: Stand und Ausfall mit dem Text des Servers', () => {
+ assert.deepEqual(stripeStatus({ ok: true, abgerufenUm: '2026-10-01T13:55:00.000Z' }, NOW), { ok: true, text: 'Stripe: Stand gerade eben' })
+ assert.deepEqual(stripeStatus({ ok: false, fehler: 'Stripe ist gerade nicht erreichbar.', abgerufenUm: null }, NOW), {
+ ok: false,
+ text: 'Stripe: Abruf fehlgeschlagen: Stripe ist gerade nicht erreichbar.',
+ })
+ assert.equal(stripeStatus({ ok: false }, NOW).text, 'Stripe: Abruf fehlgeschlagen: Stripe ist gerade nicht erreichbar.')
+ assert.deepEqual(stripeStatus({ ok: true, abgerufenUm: null }, NOW), { ok: true, text: 'Stripe: Stand unbekannt' })
+ assert.deepEqual(stripeStatus(null, NOW), { ok: true, text: 'Stripe: Stand unbekannt' })
+})
+
+test('statusItems: Bank und Stripe, fehlender Stand ist kein Fehler im Code', () => {
+ const items = statusItems({ bank: null, stripe: { ok: true, abgerufenUm: '2026-10-01T13:00:00.000Z' } }, NOW)
+ assert.deepEqual(items.map((i) => i.key), ['bank', 'stripe'])
+ assert.equal(items[0].text, 'Bank: nicht verbunden')
+ assert.equal(items[1].text, 'Stripe: Stand vor 55 Min.')
+ assert.equal(statusItems(undefined, NOW).length, 2)
+})
+
+// --- Liquiditätskopf -----------------------------------------------------------------------------------------
+
+test('breakdownRows: Kontostand minus Rücklage, Fixkosten und Puffer ergibt Verfügbar', () => {
+ const rows = breakdownRows({ kontostandCents: 10893, steuerRuecklageCents: 3268, fixkosten30Cents: 1449, mindestpufferCents: 2000 }, 4176)
+ assert.deepEqual(
+ rows.map((r) => [r.label, r.text]),
+ [
+ ['Kontostand (gebucht)', '108,93 €'],
+ ['Steuer-Rücklage', `${MINUS}32,68 €`],
+ ['Fixkosten der nächsten 30 Tage', `${MINUS}14,49 €`],
+ ['Mindestpuffer', `${MINUS}20,00 €`],
+ ['Verfügbar nach Rücklagen', '41,76 €'],
+ ]
+ )
+ assert.deepEqual(rows.map((r) => r.total), [false, false, false, false, true])
+})
+
+test('breakdownRows: Puffer 0 ohne Vorzeichen, negatives Ergebnis, fehlende Werte', () => {
+ const rows = breakdownRows({ kontostandCents: 1000, steuerRuecklageCents: 0, fixkosten30Cents: 5000, mindestpufferCents: 0 }, -4000)
+ assert.equal(rows[1].text, '0,00 €')
+ assert.equal(rows[4].text, `${MINUS}40,00 €`)
+ const none = breakdownRows(null, null)
+ assert.deepEqual(none.map((r) => r.text), ['–', '–', '–', '–', '–'])
+})
+
+test('lowPointLines: sicher und erwartet mit ganzen Euro und Datum', () => {
+ const [sicher, erwartet] = lowPointLines({
+ tiefsterSicher: { amountCents: 9444, date: '2026-10-11' },
+ tiefsterErwartet: { amountCents: 10893, date: '2026-10-01' },
+ })
+ assert.deepEqual(sicher, { key: 'sicher', label: 'sicher', amountText: '94 €', dateText: '11.10.2026', negative: false })
+ assert.deepEqual(erwartet, { key: 'erwartet', label: 'erwartet', amountText: '109 €', dateText: '01.10.2026', negative: false })
+})
+
+test('lowPointLines: negativ mit Minus U+2212, ohne Kontostand null', () => {
+ const [sicher] = lowPointLines({ tiefsterSicher: { amountCents: -12000, date: '2026-10-20' }, tiefsterErwartet: null })
+ assert.equal(sicher.amountText, `${MINUS}120 €`)
+ assert.equal(sicher.negative, true)
+ assert.deepEqual(lowPointLines({ tiefsterSicher: null, tiefsterErwartet: null }), [null, null])
+ assert.deepEqual(lowPointLines(undefined), [null, null])
+})
+
+// --- Zu erledigen --------------------------------------------------------------------------------------------
+
+const todos = (n) => Array.from({ length: n }, (_, i) => ({ art: 'abo-zahlung', dringlichkeit: 'hoch', titel: `T${i}`, text: '', datum: '2026-10-01', aktion: { typ: 'kunde-oeffnen', customerId: `c${i}` } }))
+
+test('visibleTodos: höchstens 7, Rest hinter „Alle N anzeigen“', () => {
+ assert.equal(TODO_LIMIT, 7)
+ const few = visibleTodos(todos(7))
+ assert.equal(few.shown.length, 7)
+ assert.equal(few.hidden, 0)
+ const many = visibleTodos(todos(12))
+ assert.equal(many.shown.length, 7)
+ assert.equal(many.total, 12)
+ assert.equal(many.hidden, 5)
+ assert.deepEqual(many.shown.map((t) => t.titel), ['T0', 'T1', 'T2', 'T3', 'T4', 'T5', 'T6'])
+ const all = visibleTodos(todos(12), true)
+ assert.equal(all.shown.length, 12)
+ assert.equal(all.hidden, 0)
+})
+
+test('visibleTodos: leer, ungültig, Nicht-Objekte werden ausgelassen', () => {
+ assert.deepEqual(visibleTodos([]), { shown: [], total: 0, hidden: 0 })
+ assert.deepEqual(visibleTodos(null), { shown: [], total: 0, hidden: 0 })
+ assert.deepEqual(visibleTodos(undefined), { shown: [], total: 0, hidden: 0 })
+ assert.equal(visibleTodos([null, 'x', 3, todos(1)[0]]).total, 1)
+})
+
+test('urgencyOf: Badge-Text und Ton, unbekannt gilt als Hinweis', () => {
+ assert.deepEqual(urgencyOf({ dringlichkeit: 'hoch' }), { label: 'Dringend', tone: 'danger' })
+ assert.deepEqual(urgencyOf({ dringlichkeit: 'mittel' }), { label: 'Bald', tone: 'warn' })
+ assert.deepEqual(urgencyOf({ dringlichkeit: 'info' }), { label: 'Hinweis', tone: 'muted' })
+ assert.deepEqual(urgencyOf({ dringlichkeit: 'x' }), { label: 'Hinweis', tone: 'muted' })
+ assert.deepEqual(urgencyOf(null), { label: 'Hinweis', tone: 'muted' })
+})
+
+test('todoKey: unterscheidet Aufgaben auch bei gleichem Titel', () => {
+ const [a, b] = todos(2)
+ assert.notEqual(todoKey(a, 0), todoKey(b, 1))
+ assert.notEqual(todoKey({ ...a }, 0), todoKey({ ...a }, 1))
+ assert.equal(typeof todoKey(null), 'string')
+})
+
+test('safeUrl: nur http(s)', () => {
+ assert.equal(safeUrl('https://invoice.stripe.com/i/acct_1/test_x'), 'https://invoice.stripe.com/i/acct_1/test_x')
+ assert.equal(safeUrl(' http://example.test/a '), 'http://example.test/a')
+ for (const bad of ['javascript:alert(1)', 'data:text/html,x', 'ftp://x.test', 'invoice.stripe.com/i', '', ' ', null, undefined, 5]) {
+ assert.equal(safeUrl(bad), '', String(bad))
+ }
+})
+
+test('todoButtonLabel: ein Knopf je Aktion', () => {
+ const label = (aktion) => todoButtonLabel({ aktion })
+ assert.equal(label({ typ: 'bank-verbinden' }), 'Bank verbinden')
+ assert.equal(label({ typ: 'bank-aktualisieren' }), 'Aktualisieren')
+ assert.equal(label({ typ: 'rechnung-oeffnen', hostedUrl: 'https://x.test/i' }), 'Zahlungsseite öffnen')
+ assert.equal(label({ typ: 'rechnung-oeffnen', hostedUrl: null }), 'Zu den Rechnungen')
+ assert.equal(label({ typ: 'rechnung-oeffnen', hostedUrl: 'javascript:alert(1)' }), 'Zu den Rechnungen')
+ assert.equal(label({ typ: 'zuordnen' }), 'Zuordnen')
+ assert.equal(label({ typ: 'kunde-oeffnen' }), 'Kunde öffnen')
+ assert.equal(label({ typ: 'fixkosten-uebernehmen' }), 'Übernehmen')
+ assert.equal(label({ typ: 'einstellungen' }), 'Einstellungen')
+ assert.equal(label({ typ: 'neu' }), '')
+ assert.equal(todoButtonLabel({}), '')
+ assert.equal(todoButtonLabel(null), '')
+})
+
+const suggestion = (extra = {}) => ({ key: 'hetzner', name: 'Hetzner', amountCents: 449, interval: 'monat', lastDate: '2026-09-27', count: 4, ...extra })
+
+test('fixedCostFromSuggestion: Anfrage mit nächstem Termin und Rückfrage-Text', () => {
+ assert.deepEqual(fixedCostFromSuggestion(suggestion()), {
+ payload: { name: 'Hetzner', amountCents: 449, interval: 'monat', nextDate: '2026-10-27', counterpartyMatch: 'hetzner' },
+ confirmText: 'Als Fixkosten übernehmen: Hetzner, 4,49 € monatlich?',
+ })
+ const q = fixedCostFromSuggestion(suggestion({ interval: 'quartal', lastDate: '2026-11-30', amountCents: 123456 }))
+ assert.equal(q.payload.nextDate, '2027-02-28')
+ assert.equal(q.confirmText, 'Als Fixkosten übernehmen: Hetzner, 1.234,56 € vierteljährlich?')
+ assert.equal(fixedCostFromSuggestion(suggestion({ interval: 'jahr' })).confirmText, 'Als Fixkosten übernehmen: Hetzner, 4,49 € jährlich?')
+})
+
+test('fixedCostFromSuggestion: unvollständig ergibt null', () => {
+ assert.equal(fixedCostFromSuggestion(null), null)
+ assert.equal(fixedCostFromSuggestion({}), null)
+ assert.equal(fixedCostFromSuggestion(suggestion({ name: ' ' })), null)
+ assert.equal(fixedCostFromSuggestion(suggestion({ amountCents: 0 })), null)
+ assert.equal(fixedCostFromSuggestion(suggestion({ amountCents: 4.5 })), null)
+ assert.equal(fixedCostFromSuggestion(suggestion({ interval: 'woche' })), null)
+ assert.equal(fixedCostFromSuggestion(suggestion({ lastDate: 'Mai' })), null)
+ assert.equal(fixedCostFromSuggestion(suggestion({ key: undefined })).payload.counterpartyMatch, '')
+})
+
+test('planTodoAction: jede Aktionsart des Portals', () => {
+ assert.deepEqual(planTodoAction({ typ: 'bank-verbinden' }), { kind: 'bank-connect' })
+ assert.deepEqual(planTodoAction({ typ: 'bank-aktualisieren' }), { kind: 'bank-refresh' })
+ assert.deepEqual(planTodoAction({ typ: 'rechnung-oeffnen', invoiceId: 'in_1', hostedUrl: 'https://x.test/i' }), { kind: 'open-url', url: 'https://x.test/i' })
+ assert.deepEqual(planTodoAction({ typ: 'rechnung-oeffnen', invoiceId: 'in_1', hostedUrl: null }), { kind: 'scroll', id: 'finanzen-rechnungen' })
+ assert.equal(INVOICES_ANCHOR, 'finanzen-rechnungen')
+ assert.deepEqual(planTodoAction({ typ: 'rechnung-oeffnen', hostedUrl: 'javascript:alert(1)' }), { kind: 'scroll', id: 'finanzen-rechnungen' })
+ assert.deepEqual(planTodoAction({ typ: 'zuordnen', transactionId: 'b1' }), { kind: 'assign', transactionId: 'b1' })
+ assert.deepEqual(planTodoAction({ typ: 'kunde-oeffnen', customerId: 'c2' }), { kind: 'navigate', to: '/customers/c2' })
+ assert.deepEqual(planTodoAction({ typ: 'kunde-oeffnen', customerId: 'a/b c' }), { kind: 'navigate', to: '/customers/a%2Fb%20c' })
+ assert.deepEqual(planTodoAction({ typ: 'einstellungen' }), { kind: 'navigate', to: '/finance/einstellungen' })
+ const fixed = planTodoAction({ typ: 'fixkosten-uebernehmen', vorschlag: suggestion() })
+ assert.equal(fixed.kind, 'fixed-cost')
+ assert.equal(fixed.payload.nextDate, '2026-10-27')
+ assert.equal(fixed.confirmText, 'Als Fixkosten übernehmen: Hetzner, 4,49 € monatlich?')
+})
+
+test('planTodoAction: fehlende Angaben und unbekannte Aktionen geben eine Meldung', () => {
+ for (const aktion of [
+ { typ: 'zuordnen' },
+ { typ: 'zuordnen', transactionId: '' },
+ { typ: 'kunde-oeffnen' },
+ { typ: 'kunde-oeffnen', customerId: ' ' },
+ { typ: 'fixkosten-uebernehmen' },
+ { typ: 'fixkosten-uebernehmen', vorschlag: suggestion({ amountCents: -1 }) },
+ { typ: 'unbekannt' },
+ null,
+ undefined,
+ ]) {
+ const plan = planTodoAction(aktion)
+ assert.equal(plan.kind, 'error', JSON.stringify(aktion))
+ assert.ok(plan.message.length > 0)
+ }
+})