From 88a98b858f09fe79438b7e7796655e0ac46e1bdf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:36:19 +0000 Subject: [PATCH] Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste Co-Authored-By: Claude Sonnet 5.5 --- src/components/finance/LiquidityHead.jsx | 91 ++++++++ src/components/finance/StatusLine.jsx | 57 +++++ src/components/finance/TodoList.jsx | 85 ++++++++ src/hooks/useFinanceOverview.js | 43 ++++ src/lib/financeFormat.js | 41 ++++ src/lib/financeView.js | 218 +++++++++++++++++++ src/pages/FinancePage.jsx | 208 +++++++++--------- src/styles/global.css | 58 +++++ tests/unit/financeFormat.test.mjs | 53 +++++ tests/unit/financeView.test.mjs | 258 +++++++++++++++++++++++ 10 files changed, 1001 insertions(+), 111 deletions(-) create mode 100644 src/components/finance/LiquidityHead.jsx create mode 100644 src/components/finance/StatusLine.jsx create mode 100644 src/components/finance/TodoList.jsx create mode 100644 src/hooks/useFinanceOverview.js create mode 100644 src/lib/financeView.js create mode 100644 tests/unit/financeView.test.mjs 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 && + ))} +
+ +
+ {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 = ( +
+
+ ) + } 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 ? ( - - ) : ( -
- - - - - - {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 ( - - - - - - - - - - - ) - })} - -
Nr.KundeDatumFälligBetragOffenStatus
{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) + } +})