Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:12:18 +00:00
1 parent 0069a62a09
commit 88a98b858f
10 files changed
+1001 -111

No files matched your search

+91
View File
@@ -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 (
<div className="kpi-grid fin-head">
<Kpi
label="Kontostand"
value={euroWhole(k.kontostandCents)}
sub={hasBalance ? 'gebucht' : 'Noch kein Kontostand abgerufen'}
/>
<Kpi
label="Verfügbar nach Rücklagen"
value={euroWhole(k.verfuegbarCents)}
accent={shortfall ? 'var(--danger)' : 'var(--accent)'}
sub={
<>
{shortfall && <div className="fin-flag">Rücklagen nicht gedeckt</div>}
{isNum(k.verfuegbarCents) && (
<>
<button
type="button"
className="fin-link"
aria-expanded={open}
aria-controls={listId}
onClick={() => setOpen((v) => !v)}
>
Aufschlüsselung {open ? <FaChevronUp aria-hidden="true" /> : <FaChevronDown aria-hidden="true" />}
</button>
{open && (
<ul id={listId} className="fin-breakdown">
{breakdownRows(k.aufschluesselung, k.verfuegbarCents).map((row) => (
<li key={row.key} className={row.total ? 'fin-breakdown-total' : undefined}>
<span>{row.label}</span>
<span className="fin-amount">{row.text}</span>
</li>
))}
</ul>
)}
</>
)}
{!isNum(k.verfuegbarCents) && <div>Ohne Kontostand nicht berechenbar</div>}
</>
}
/>
<Kpi
label="Tiefster Stand in 30 Tagen"
accent={anyLowNegative ? 'var(--danger)' : 'var(--info)'}
value={
lows.some(Boolean) ? (
<div className="fin-lows">
{lows.map(
(low) =>
low && (
<div key={low.key} className="fin-low">
<span className="fin-low-label">{low.label}:</span>
<span className="fin-amount">{low.amountText}</span>
<span className="fin-low-when">
am {low.dateText}
{low.negative && <span className="fin-flag"> · im Minus</span>}
</span>
</div>
)
)}
</div>
) : (
'–'
)
}
sub={lows.some(Boolean) ? '„erwartet“ zählt offene Rechnungen und Abos dazu' : 'Ohne Kontostand nicht berechenbar'}
/>
</div>
)
}
+57
View File
@@ -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 (
<div className="fin-status-wrap">
<div className="fin-status">
<div className="fin-status-items">
{items.map((item) => (
<span key={item.key} className={`fin-status-item${item.ok ? '' : ' fin-status-warn'}`}>
{!item.ok && <FaTriangleExclamation aria-hidden="true" />}
{item.text}
</span>
))}
</div>
<Button size="sm" variant="ghost" onClick={handleRefresh} disabled={refreshing}>
<FaRotate className={refreshing ? 'fin-spin' : undefined} aria-hidden="true" />{' '}
{refreshing ? 'Aktualisiere …' : 'Aktualisieren'}
</Button>
</div>
{hint && (
<p className={`fin-hint${hint.limited ? '' : ' fin-hint-warn'}`} role={hint.limited ? 'status' : 'alert'}>
{hint.text}
</p>
)}
</div>
)
}
+85
View File
@@ -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 (
<Card
title="Zu erledigen"
actions={total > 0 ? <Badge tone="muted">{total} offen</Badge> : null}
className="fin-todo-card"
bodyStyle={{ padding: 0 }}
>
{total === 0 ? (
<EmptyState title="Nichts offen" />
) : (
<>
<ul className="fin-todos">
{shown.map((todo, index) => {
const key = todoKey(todo, index)
const urgency = urgencyOf(todo)
const label = todoButtonLabel(todo)
return (
<li key={key} className={`fin-todo fin-todo-${todo.dringlichkeit || 'info'}`}>
<Badge tone={urgency.tone}>{urgency.label}</Badge>
<div className="fin-todo-main">
<div className="fin-todo-title">{todo.titel}</div>
{todo.text && <div className="fin-todo-text muted">{todo.text}</div>}
{failure?.key === key && (
<div className="fin-hint fin-hint-warn" role="alert">
{failure.message}
</div>
)}
</div>
{label && (
<Button size="sm" onClick={() => run(todo, key)} disabled={running !== null}>
{running === key ? 'Moment …' : label}
</Button>
)}
</li>
)
})}
</ul>
{total > TODO_LIMIT && (
<div className="fin-todos-more">
<Button size="sm" variant="ghost" onClick={() => setExpanded((v) => !v)}>
{expanded ? 'Weniger anzeigen' : `Alle ${total} anzeigen`}
</Button>
</div>
)}
</>
)}
</Card>
)
}
+43
View File
@@ -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 }
}
+41
View File
@@ -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 : []
+218
View File
@@ -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.')
}
}
+97 -111
View File
@@ -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 = (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Finanzen werden geladen …</p>
</div>
)
} else if (!data) {
body = (
<Card>
<p role="alert" style={{ fontWeight: 600, marginTop: 0 }}>{error || 'Die Übersicht konnte nicht geladen werden.'}</p>
<Button variant="primary" onClick={reload}>Erneut versuchen</Button>
</Card>
)
} else {
body = (
<div className="fin-stack">
{error && (
<p className="fin-hint fin-hint-warn" role="alert">
Die Übersicht konnte nicht neu geladen werden: {error}{' '}
<Button size="sm" variant="ghost" onClick={reload} disabled={loading}>Erneut versuchen</Button>
</p>
)}
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
<LiquidityHead kopf={data.kopf} />
<TodoList aufgaben={data.aufgaben} onAction={handleAction} />
</div>
)
}
return (
<div className="page">
<PageHeader
title="Finanzen"
subtitle="Rechnungen aus InvoiceNinja: Übersicht und Status. Neue Rechnungen direkt in InvoiceNinja schreiben."
actions={
<>
<Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer">
<FaArrowUpRightFromSquare /> InvoiceNinja
</Button>
<Button variant="ghost" onClick={() => { load(); refreshSummary() }}><FaRotate /> Aktualisieren</Button>
</>
}
/>
<div className="kpi-grid">
<Kpi label="Offener Betrag" value={sumLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
<Kpi label="Überfällig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} überfällig`} accent="var(--danger)" />
<Kpi label="Bezahlt (Monat)" value={sumLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
<Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" />
</div>
<Card pad={false}>
<div className="ui-card-head">
<div className="seg">
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Überfällig'], ['paid', 'Bezahlt']].map(([id, label]) => (
<button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button>
))}
</div>
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen …" value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
</div>
<div className="ui-card-body" style={{ padding: 0 }}>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<div className="empty text-red">{error}</div>
) : filtered.length === 0 ? (
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen für diesen Filter." />
) : (
<div style={{ overflowX: 'auto' }}>
<table className="ui-table">
<thead>
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Fällig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{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 (
<tr key={inv.id}>
<td className="mono">{inv.number || inv.id?.slice(-6)}</td>
<td>{inv.client_name || '-'}</td>
<td className="muted">{inv.date || '-'}</td>
<td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td>
<td>{formatMoney(inv.amount)}</td>
<td style={{ color: Number(inv.balance) > 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)}</td>
<td><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
<td>
<Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF">
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
</Button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</Card>
<PageHeader title="Finanzen" subtitle="Kontostand, Aufgaben und Zahlungen im Blick" />
{body}
</div>
)
}
+58
View File
@@ -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; }
}
+53
View File
@@ -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)
})
+258
View File
@@ -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)
}
})