diff --git a/src/components/finance/AssignDialog.jsx b/src/components/finance/AssignDialog.jsx
new file mode 100644
index 0000000..92e5e6f
--- /dev/null
+++ b/src/components/finance/AssignDialog.jsx
@@ -0,0 +1,316 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { FaXmark } from 'react-icons/fa6'
+import { Badge, Button } from '../ui'
+import { useDialogFocus } from '../../hooks/useDialogFocus'
+import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
+import {
+ assignBody,
+ assignModel,
+ invoiceAssignConfirmText,
+ transactionRows,
+} from '../../lib/financeView'
+
+const NOTE_MAX = 500
+
+/**
+ * Dialog „Buchung zuordnen“. Auswahl je nach Buchung (siehe assignModel): Vorschläge, andere offene Rechnung,
+ * Stripe-Auszahlung (aus den Vorschlägen), Fixkosten (nur Ausgänge, aus der Fixkostenliste), Kategorie, dazu eine
+ * Notiz. Vorgemerkte Buchungen (PDNG) bekommen nur Kategorien und Fixkosten. Eine Rechnung wird in Stripe als
+ * bezahlt markiert und das lässt sich nicht rückgängig machen, deshalb zweistufig: erst „Zuordnen“, dann der
+ * Bestätigungstext mit „Ja, als bezahlt markieren“; vorher geht keine Anfrage raus. Fehler des Servers (409 Betrag
+ * weicht ab, 502 nicht gespeichert …) stehen im Dialog, der offen bleibt. Eine bestehende Zuordnung (außer Rechnung)
+ * lässt sich mit „Zuordnung lösen“ aufheben.
+ *
+ * invoices = offene und überfällige Rechnungen (invoicesLoading / invoicesError für den Ladezustand).
+ * onDone(buchung) nach Erfolg (Aufrufer lädt neu und schließt); onClose({ refresh }) bei Abbruch, refresh = ein
+ * Fehler trat auf, der Stand kann sich geändert haben.
+ */
+export default function AssignDialog({ transaction, invoices = [], invoicesLoading = false, invoicesError = '', onClose, onDone }) {
+ const tx = transaction || {}
+ const row = useMemo(() => transactionRows([tx])[0], [tx])
+ const [fixed, setFixed] = useState({ list: [], loading: false, error: '' })
+ const [selected, setSelected] = useState('')
+ const [note, setNote] = useState('')
+ const [confirming, setConfirming] = useState(false)
+ const [busy, setBusy] = useState(null) // 'assign' | 'unassign'
+ const [error, setError] = useState('')
+ const lockRef = useRef(false)
+ const aliveRef = useRef(true)
+ const dirtyRef = useRef(false)
+ const dialogRef = useRef(null)
+ const confirmRef = useRef(null)
+
+ const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0
+ const unassigned = !tx.category
+
+ const onOverlayKeyDown = useDialogFocus(dialogRef)
+
+ useEffect(() => {
+ aliveRef.current = true
+ return () => {
+ aliveRef.current = false
+ }
+ }, [])
+
+ // Fixkosten nur für Ausgänge ohne Zuordnung
+ useEffect(() => {
+ if (!outgoing || !unassigned) return undefined
+ let cancelled = false
+ setFixed({ list: [], loading: true, error: '' })
+ getFixedCosts()
+ .then((data) => {
+ if (!cancelled) setFixed({ list: Array.isArray(data?.fixkosten) ? data.fixkosten : [], loading: false, error: '' })
+ })
+ .catch((err) => {
+ if (!cancelled) setFixed({ list: [], loading: false, error: err?.message || 'Die Fixkosten konnten nicht geladen werden.' })
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [outgoing, unassigned])
+
+ const model = useMemo(() => assignModel(tx, { invoices, fixedCosts: fixed.list }), [tx, invoices, fixed.list])
+ const option = model.byKey[selected] || null
+
+ function close() {
+ if (lockRef.current) return
+ onClose({ refresh: dirtyRef.current })
+ }
+
+ useEffect(() => {
+ const onKey = (e) => {
+ // Escape schließt zuerst nur die offene Auswahl (z. B. ein Aufklappmenü): dann nicht den Dialog
+ if (e.key === 'Escape' && !e.defaultPrevented) close()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ })
+
+ function choose(key) {
+ setSelected(key)
+ setConfirming(false)
+ if (error) setError('')
+ }
+
+ useEffect(() => {
+ if (confirming) confirmRef.current?.focus()
+ }, [confirming])
+
+ async function doAssign() {
+ if (lockRef.current || !option || option.disabled) return
+ lockRef.current = true
+ setBusy('assign')
+ setError('')
+ try {
+ const data = await assignTransaction(tx.$id, assignBody(option, note))
+ onDone(data?.buchung || null)
+ } catch (err) {
+ dirtyRef.current = true
+ if (aliveRef.current) {
+ setError(err?.message || 'Die Zuordnung ist fehlgeschlagen.')
+ setConfirming(false)
+ }
+ } finally {
+ lockRef.current = false
+ if (aliveRef.current) setBusy(null)
+ }
+ }
+
+ // Erster Schritt: bei einer Rechnung nur den Bestätigungstext zeigen, noch keine Anfrage
+ function primary() {
+ if (!option || option.disabled || lockRef.current) return
+ if (option.type === 'invoice' && !confirming) {
+ setConfirming(true)
+ return
+ }
+ doAssign()
+ }
+
+ async function doUnassign() {
+ if (lockRef.current) return
+ lockRef.current = true
+ setBusy('unassign')
+ setError('')
+ try {
+ const data = await unassignTransaction(tx.$id)
+ onDone(data?.buchung || null)
+ } catch (err) {
+ dirtyRef.current = true
+ if (aliveRef.current) setError(err?.message || 'Die Zuordnung konnte nicht gelöst werden.')
+ } finally {
+ lockRef.current = false
+ if (aliveRef.current) setBusy(null)
+ }
+ }
+
+ const disabled = busy !== null
+ const showInvoiceState = model.incoming && !model.pending && !model.assigned
+ const invoiceSelectValue = model.invoices.some((o) => o.key === selected) ? selected : ''
+ const fixedSelectValue = model.fixedCosts.some((o) => o.key === selected) ? selected : ''
+
+ const radio = (o) => (
+
+ )
+
+ return (
+
+
+
+
Buchung zuordnen
+
+
+ - Datum
- {row?.dateText}{row?.pending && <> vorgemerkt>}
+ - Gegenpartei
- {row?.counterparty}
+ {row?.remittance && - Verwendungszweck
- {row.remittance}
}
+ - Betrag
- {row?.amountText}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {model.assigned ? (
+ <>
+
+ Zugeordnet: {model.currentText}
+
+ {!model.canUnassign && (
+
Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.
+ )}
+
+
+ {model.canUnassign && (
+
+ )}
+
+ >
+ ) : (
+ <>
+ {model.hint &&
{model.hint}
}
+
+ {showInvoiceState && invoicesLoading &&
Offene Rechnungen werden geladen …
}
+ {showInvoiceState && invoicesError && (
+
Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}
+ )}
+ {outgoing && fixed.loading &&
Fixkosten werden geladen …
}
+ {outgoing && fixed.error &&
Die Fixkosten konnten nicht geladen werden: {fixed.error}
}
+
+ {model.suggestions.length > 0 && (
+
+ )}
+
+ {model.invoices.length > 0 && (
+
+
+
+
+ )}
+
+ {model.fixedCosts.length > 0 && (
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ {confirming && option ? (
+
+
{invoiceAssignConfirmText(option.number || option.label)}
+
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/src/components/finance/InvoiceCreateModal.jsx b/src/components/finance/InvoiceCreateModal.jsx
new file mode 100644
index 0000000..67662cd
--- /dev/null
+++ b/src/components/finance/InvoiceCreateModal.jsx
@@ -0,0 +1,300 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
+import { AutocompleteInput, Button } from '../ui'
+import { useCustomers } from '../../hooks/useCustomers'
+import { useDialogFocus } from '../../hooks/useDialogFocus'
+import { createInvoice } from '../../lib/financeApi'
+import { canInvoice, euro } from '../../lib/financeFormat'
+import {
+ INVOICE_LIMITS,
+ customerName,
+ customerPickerOptions,
+ customerSuggestions,
+ finalizedFilter,
+ invoiceFailureMessage,
+ invoiceTotalCents,
+ isFinalizedNotSent,
+ lineTotalCents,
+ resolveCustomerText,
+ validateInvoiceForm,
+} from '../../lib/financeView'
+
+let itemCounter = 0
+const newItem = () => ({ uid: ++itemCounter, description: '', quantity: '1', price: '' })
+
+/**
+ * Dialog „Rechnung schreiben“. Kunde (nur feste Kunden mit E-Mail wählbar; mit `fixedCustomer` steht er fest),
+ * Positionen (Beschreibung, Menge, Einzelpreis in Euro), Zahlungsziel (Standard 14 Tage), Notiz, Summe und der
+ * Hinweis nach § 19 UStG. „Als Entwurf speichern“ und „Senden“ sperren sich gegenseitig, solange die Anfrage läuft;
+ * Fehler des Servers stehen im Dialog.
+ *
+ * onCreated(rechnung, { senden }) nach Erfolg; wurde die Rechnung festgeschrieben, aber nicht versendet (502 mit
+ * invoiceId), kommt onCreated(null, { senden: true, notice, tone: 'warn', finalizedFilter }) und der Dialog schließt
+ * (die Rechnung steht dann „offen“ in der Liste und lässt sich dort erneut senden). Danach ruft der Dialog onClose().
+ */
+export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null }) {
+ if (!open) return null
+ // Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
+ return fixedCustomer ? (
+
+ ) : (
+
+ )
+}
+
+function PickerForm({ onClose, onCreated }) {
+ const { customers, loading, error } = useCustomers()
+ return
+}
+
+function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {
+ const [customerText, setCustomerText] = useState('')
+ const [items, setItems] = useState(() => [newItem()])
+ const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays))
+ const [memo, setMemo] = useState('')
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(null) // 'draft' | 'send'
+ const lockRef = useRef(false)
+ const aliveRef = useRef(true)
+ const dialogRef = useRef(null)
+
+ const onOverlayKeyDown = useDialogFocus(dialogRef)
+
+ useEffect(() => {
+ aliveRef.current = true
+ return () => {
+ aliveRef.current = false
+ }
+ }, [])
+
+ useEffect(() => {
+ const onKey = (e) => {
+ // Escape schließt auch die Kundenliste (Autovervollständigung): dann nicht den ganzen Dialog
+ if (e.key === 'Escape' && !e.defaultPrevented && !lockRef.current) onClose()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [onClose])
+
+ const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
+ const resolved = fixedCustomer
+ ? { state: canInvoice(fixedCustomer).ok ? 'ok' : 'blocked', option: null }
+ : resolveCustomerText(options, customerText)
+ const customer = fixedCustomer || resolved.option?.customer || null
+ const customerBlocked = fixedCustomer ? canInvoice(fixedCustomer) : resolved.option ? { ok: resolved.option.ok, reason: resolved.option.reason } : { ok: true }
+ const total = invoiceTotalCents(items)
+
+ const updateItem = (uid, field, value) => {
+ setItems((list) => list.map((it) => (it.uid === uid ? { ...it, [field]: value } : it)))
+ if (error) setError('')
+ }
+
+ async function submit(senden) {
+ if (lockRef.current) return
+ const check = validateInvoiceForm({ customer, items, days, memo })
+ if (!check.ok) {
+ setError(check.error)
+ return
+ }
+ lockRef.current = true
+ setBusy(senden ? 'send' : 'draft')
+ setError('')
+ try {
+ const data = await createInvoice({ ...check.payload, senden })
+ if (onCreated) onCreated(data?.rechnung || null, { senden })
+ onClose()
+ } catch (err) {
+ if (isFinalizedNotSent(err)) {
+ // Die Rechnung steht bei Stripe: keine zweite anlegen, die Liste zeigt sie
+ if (onCreated) onCreated(null, { senden: true, notice: invoiceFailureMessage(err), tone: 'warn', finalizedFilter: finalizedFilter(err) })
+ onClose()
+ return
+ }
+ if (aliveRef.current) setError(invoiceFailureMessage(err))
+ } finally {
+ lockRef.current = false
+ if (aliveRef.current) setBusy(null)
+ }
+ }
+
+ const disabled = busy !== null
+
+ return (
+
+
+
+
Rechnung schreiben
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Enter sendet nie (das wäre eine Mail an den Kunden): nur die beiden Knöpfe unten lösen aus */}
+
+
+
+ )
+}
diff --git a/src/components/finance/InvoiceTable.jsx b/src/components/finance/InvoiceTable.jsx
new file mode 100644
index 0000000..3032822
--- /dev/null
+++ b/src/components/finance/InvoiceTable.jsx
@@ -0,0 +1,277 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { FaPlus, FaSpinner } from 'react-icons/fa6'
+import { Badge, Button, Card, EmptyState } from '../ui'
+import InvoiceCreateModal from './InvoiceCreateModal'
+import Pager from './Pager'
+import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
+import { pageOf } from '../../lib/financeFormat'
+import {
+ DEFAULT_INVOICE_FILTER,
+ INVOICES_ANCHOR,
+ INVOICE_FILTERS,
+ ageGroupCells,
+ finalizedFilter,
+ invoiceConfirmText,
+ invoiceDoneMessage,
+ invoiceFailureMessage,
+ invoiceRows,
+ isFinalizedNotSent,
+} from '../../lib/financeView'
+
+const PAGE_SIZE = 50
+const RUNNING_LABEL = { send: 'Sende …', resend: 'Sende …', void: 'Storniere …', delete: 'Lösche …' }
+const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, delete: deleteInvoice }
+
+/**
+ * Block 6: Rechnungen. Filter Offen / Überfällig / Bezahlt / Entwürfe / Alle (Standard Offen), Altersgruppen der
+ * offenen Rechnungen (nur mit `altersgruppen`, also nicht in der Kundenansicht), Tabelle mit 50 Zeilen je Seite
+ * und die Aktionen je Status (siehe invoiceActions). Löschen, Stornieren und Erinnerung fragen vorher nach; solange
+ * eine Anfrage läuft, sind alle Aktionen gesperrt. „Rechnung schreiben“ öffnet den Dialog.
+ *
+ * customerId filtert auf einen Kunden; `customer` (Appwrite-Dokument) legt zusätzlich den Kunden im Dialog fest
+ * (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Karte bis 700 px) wird jede
+ * Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen.
+ */
+export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, onChanged }) {
+ const filterCustomer = customerId || customer?.$id || null
+ const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER)
+ const [reloadKey, setReloadKey] = useState(0)
+ const [result, setResult] = useState({ key: '', list: [] }) // Antwort samt Filter, zu dem sie gehört
+ const [error, setError] = useState(null) // { key, message }
+ const [page, setPage] = useState(1)
+ const [busy, setBusy] = useState(null) // `${id}:${aktion}` der laufenden Anfrage
+ const [message, setMessage] = useState(null) // { tone: 'ok' | 'warn', text }
+ const [createOpen, setCreateOpen] = useState(false)
+ const lockRef = useRef(false)
+ const aliveRef = useRef(true)
+ const seqRef = useRef(0)
+ const onChangedRef = useRef(onChanged)
+ onChangedRef.current = onChanged
+
+ useEffect(() => {
+ aliveRef.current = true
+ return () => {
+ aliveRef.current = false
+ }
+ }, [])
+
+ const key = `${status}|${filterCustomer || ''}`
+ useEffect(() => {
+ // Nur die jüngste Anfrage zählt (Filterwechsel während des Ladens)
+ const mine = ++seqRef.current
+ getInvoices({ status, kunde: filterCustomer })
+ .then((data) => {
+ if (!aliveRef.current || mine !== seqRef.current) return
+ setResult({ key, list: Array.isArray(data?.rechnungen) ? data.rechnungen : [] })
+ setError(null)
+ })
+ .catch((err) => {
+ if (!aliveRef.current || mine !== seqRef.current) return
+ setError({ key, message: err?.message || 'Die Rechnungen konnten nicht geladen werden.' })
+ })
+ }, [status, filterCustomer, key, reloadKey])
+
+ const reload = () => setReloadKey((k) => k + 1)
+ const changed = () => {
+ reload()
+ if (onChangedRef.current) onChangedRef.current()
+ }
+
+ const current = result.key === key
+ const invoices = current ? result.list : []
+ const rows = useMemo(() => invoiceRows(invoices), [invoices])
+ const byId = useMemo(() => Object.fromEntries(invoices.map((inv) => [inv.id, inv])), [invoices])
+ const paged = pageOf(rows, page, PAGE_SIZE)
+ const loadError = error && error.key === key ? error.message : ''
+ const loading = !current && !loadError
+ const ages = ageGroupCells(filterCustomer ? null : altersgruppen)
+
+ function pickFilter(next) {
+ if (next === status) return
+ setStatus(next)
+ setPage(1)
+ setMessage(null)
+ }
+
+ async function run(row, action) {
+ if (lockRef.current) return
+ const invoice = byId[row.id]
+ const confirmText = invoiceConfirmText(action.key, invoice)
+ if (confirmText && !window.confirm(confirmText)) return
+ lockRef.current = true
+ setBusy(`${row.id}:${action.key}`)
+ setMessage(null)
+ try {
+ const data = await CALLS[action.key](row.id)
+ if (aliveRef.current) setMessage({ tone: 'ok', text: invoiceDoneMessage(action.key, data?.rechnung) })
+ if (aliveRef.current) changed()
+ } catch (err) {
+ if (!aliveRef.current) return
+ setMessage({ tone: 'warn', text: invoiceFailureMessage(err) })
+ if (isFinalizedNotSent(err)) {
+ // Festgeschrieben: die Rechnung ist jetzt offen und steht im Filter „Offen“
+ setStatus(finalizedFilter(err))
+ setPage(1)
+ }
+ // Der Stand bei Stripe kann ein anderer sein (schon gesendet, schon bezahlt, gelöscht): neu laden
+ changed()
+ } finally {
+ lockRef.current = false
+ if (aliveRef.current) setBusy(null)
+ }
+ }
+
+ function handleCreated(rechnung, info = {}) {
+ if (info.notice) {
+ setMessage({ tone: info.tone || 'ok', text: info.notice })
+ if (info.finalizedFilter) setStatus(info.finalizedFilter)
+ } else {
+ setMessage({ tone: 'ok', text: invoiceDoneMessage(info.senden ? 'create-send' : 'create-draft', rechnung) })
+ // Der neue Eintrag soll sichtbar sein: Entwurf unter „Entwürfe“, gesendete Rechnung unter „Offen“
+ setStatus(rechnung?.status === 'entwurf' ? 'entwurf' : 'offen')
+ }
+ setPage(1)
+ changed()
+ }
+
+ return (
+
+ setCreateOpen(true)}>
+ Rechnung schreiben
+
+ }
+ >
+ {ages.length > 0 && (
+
+ {ages.map((cell) => (
+ -
+ {cell.label}
+ {cell.count} {cell.count === 1 ? 'Rechnung' : 'Rechnungen'}
+ {cell.sumText}
+
+ ))}
+
+ )}
+
+
+
+ {INVOICE_FILTERS.map((f) => (
+
+ ))}
+
+
+
+ {message && (
+
+ {message.text}
+
+ )}
+
+ {loading ? (
+
+
+
Rechnungen werden geladen …
+
+ ) : loadError ? (
+
+
{loadError}
+
+
+ ) : rows.length === 0 ? (
+
+ ) : (
+
+
+ Rechnungen, neueste zuerst
+
+
+ | Nr. |
+ Kunde |
+ Datum |
+ Fällig |
+ Betrag |
+ Offen |
+ Status |
+ Aktionen |
+
+
+
+ {paged.items.map((row) => (
+
+ |
+ {row.numberText}
+ {row.isAbo && · Abo}
+ |
+ {row.customerText} |
+ {row.createdText} |
+ {row.dueText} |
+ {row.totalText} |
+ {row.remainingText} |
+
+
+ {row.statusLabel}
+ {row.statusNote && {row.statusNote}}
+
+ |
+
+
+ {row.actions.map((action) =>
+ action.url ? (
+
+ ) : (
+
+ )
+ )}
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ setCreateOpen(false)}
+ onCreated={handleCreated}
+ fixedCustomer={customer}
+ />
+
+ )
+}
diff --git a/src/components/finance/Pager.jsx b/src/components/finance/Pager.jsx
new file mode 100644
index 0000000..ed635a8
--- /dev/null
+++ b/src/components/finance/Pager.jsx
@@ -0,0 +1,23 @@
+import { Button } from '../ui'
+
+/**
+ * Blätterleiste „Zurück · Seite 2 von 5 · 230 Buchungen · Weiter“; bei nur einer Seite erscheint sie nicht.
+ * `noun` ist die Mehrzahl, z. B. „Buchungen“.
+ */
+export default function Pager({ page, pages, total, noun = 'Einträge', onPage }) {
+ if (pages <= 1) return null
+ return (
+
+ )
+}
diff --git a/src/components/finance/TransactionTable.jsx b/src/components/finance/TransactionTable.jsx
new file mode 100644
index 0000000..7496b0c
--- /dev/null
+++ b/src/components/finance/TransactionTable.jsx
@@ -0,0 +1,113 @@
+import { useMemo, useState } from 'react'
+import { FaSpinner, FaTriangleExclamation } from 'react-icons/fa6'
+import { Badge, Button, Card, EmptyState } from '../ui'
+import Pager from './Pager'
+import { pageOf } from '../../lib/financeFormat'
+import { transactionRows } from '../../lib/financeView'
+
+const PAGE_SIZE = 50
+export const TRANSACTIONS_ANCHOR = 'finanzen-buchungen'
+
+/**
+ * Block 7: Buchungen. Alle gespeicherten Buchungen, neueste zuerst, 50 je Seite: Datum, Gegenpartei mit
+ * Verwendungszweck (zweizeilig), Betrag mit Vorzeichen, Zuordnung (bei offenen Buchungen der stärkste Vorschlag als
+ * Text) und der Knopf „Zuordnen“ bzw. „Ändern“. Vorgemerkte Buchungen (PDNG) tragen „vorgemerkt“. Die Daten kommen
+ * vom Aufrufer (useFinanceTransactions), damit Seite und Zuordnen-Dialog dieselbe Liste benutzen.
+ * onAssign(buchung) öffnet den Dialog. Schmal (Karte bis 640 px) wird jede Zeile ein Raster: Datum und Betrag /
+ * Gegenpartei und Verwendungszweck / Zuordnung und Knopf.
+ */
+export default function TransactionTable({ transactions, stripeFehler = '', loaded = false, loading = false, error = '', onReload, onAssign }) {
+ const [page, setPage] = useState(1)
+ const rows = useMemo(() => transactionRows(transactions), [transactions])
+ const paged = pageOf(rows, page, PAGE_SIZE)
+
+ let body
+ if (!loaded && loading) {
+ body = (
+
+
+
Buchungen werden geladen …
+
+ )
+ } else if (!loaded) {
+ body = (
+
+
{error || 'Die Buchungen konnten nicht geladen werden.'}
+
+
+ )
+ } else if (rows.length === 0) {
+ body =
+ } else {
+ body = (
+
+
+ Buchungen, neueste zuerst
+
+
+ | Datum |
+ Gegenpartei und Verwendungszweck |
+ Betrag |
+ Zuordnung |
+ Aktion |
+
+
+
+ {paged.items.map((row) => (
+
+ | {row.dateText} |
+
+ {row.counterparty}
+ {row.remittance && (
+ {row.remittance}
+ )}
+ |
+ {row.amountText} |
+
+
+ {row.assignmentText}
+ {row.pending && vorgemerkt}
+
+ {row.suggestionText && {row.suggestionText} }
+ {row.note && Notiz: {row.note} }
+ |
+
+ {row.action && (
+
+ )}
+ |
+
+ ))}
+
+
+
+ )
+ }
+
+ return (
+
+
+ {loaded && error && (
+
+ Die Buchungen konnten nicht neu geladen werden: {error}{' '}
+
+
+ )}
+ {stripeFehler && (
+
+ {stripeFehler}
+
+ )}
+ {body}
+
+
+
+ )
+}
diff --git a/src/hooks/useDialogFocus.js b/src/hooks/useDialogFocus.js
new file mode 100644
index 0000000..e398a6d
--- /dev/null
+++ b/src/hooks/useDialogFocus.js
@@ -0,0 +1,39 @@
+import { useCallback, useEffect } from 'react'
+
+const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
+
+/**
+ * Fokus für ein Dialog-Overlay: beim Öffnen springt der Fokus in den Dialog (`dialogRef`), beim Schließen zurück zum
+ * Element, das ihn vorher hatte (falls es noch da ist). Der zurückgegebene onKeyDown gehört an das Overlay: Tab und
+ * Umschalt+Tab bleiben im Dialog (sonst landet man hinter dem Overlay in der Seite).
+ */
+export function useDialogFocus(dialogRef) {
+ useEffect(() => {
+ const opener = document.activeElement
+ dialogRef.current?.focus()
+ return () => {
+ if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus()
+ }
+ }, [dialogRef])
+
+ return useCallback((e) => {
+ if (e.key !== 'Tab') return
+ const overlay = e.currentTarget
+ const items = [...overlay.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0)
+ if (items.length === 0) {
+ e.preventDefault()
+ return
+ }
+ const first = items[0]
+ const last = items[items.length - 1]
+ const active = document.activeElement
+ const onDialogBox = active === dialogRef.current // der Dialog selbst hat den Fokus (tabIndex -1)
+ if (e.shiftKey && (active === first || onDialogBox || !overlay.contains(active))) {
+ e.preventDefault()
+ last.focus()
+ } else if (!e.shiftKey && (active === last || !overlay.contains(active))) {
+ e.preventDefault()
+ first.focus()
+ }
+ }, [dialogRef])
+}
diff --git a/src/hooks/useFinanceTransactions.js b/src/hooks/useFinanceTransactions.js
new file mode 100644
index 0000000..699ebd8
--- /dev/null
+++ b/src/hooks/useFinanceTransactions.js
@@ -0,0 +1,49 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { getTransactions } from '../lib/financeApi'
+
+const FALLBACK_ERROR = 'Die Buchungen konnten nicht geladen werden.'
+
+/**
+ * Lädt die gespeicherten Buchungen samt Zuordnungsvorschlägen (GET /buchungen, höchstens 1000, neueste zuerst).
+ * `transactions` bleibt beim erneuten Laden stehen; `loaded` ist true, sobald einmal geladen wurde (auch leer);
+ * `stripeFehler` ist der Hinweis des Servers, wenn Stripe die Vorschläge nicht liefern konnte. reload() wirft nie.
+ */
+export function useFinanceTransactions() {
+ const [state, setState] = useState({ transactions: [], stripeFehler: '', loaded: false })
+ 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 data = await getTransactions()
+ if (current()) {
+ setState({
+ transactions: Array.isArray(data?.buchungen) ? data.buchungen : [],
+ stripeFehler: typeof data?.stripeFehler === 'string' ? data.stripeFehler : '',
+ loaded: true,
+ })
+ }
+ } 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 { ...state, loading, error, reload }
+}
diff --git a/src/hooks/useOpenInvoices.js b/src/hooks/useOpenInvoices.js
new file mode 100644
index 0000000..7917db1
--- /dev/null
+++ b/src/hooks/useOpenInvoices.js
@@ -0,0 +1,36 @@
+import { useEffect, useState } from 'react'
+import { getInvoices } from '../lib/financeApi'
+
+const FALLBACK_ERROR = 'Die offenen Rechnungen konnten nicht geladen werden.'
+
+/**
+ * Offene und überfällige Rechnungen für den Zuordnen-Dialog. Lädt jedes Mal frisch, wenn `active` true wird
+ * (damit der Dialog nie auf einem alten Stand arbeitet), und leert die Liste, wenn es wieder false ist.
+ * Scheitert Stripe, bleibt `invoices` leer und `error` nennt den Grund (Kategorien lassen sich trotzdem wählen).
+ */
+export function useOpenInvoices(active) {
+ const [state, setState] = useState({ invoices: [], loading: false, error: '' })
+
+ useEffect(() => {
+ if (!active) {
+ setState({ invoices: [], loading: false, error: '' })
+ return undefined
+ }
+ let cancelled = false
+ setState({ invoices: [], loading: true, error: '' })
+ Promise.all([getInvoices({ status: 'offen' }), getInvoices({ status: 'ueberfaellig' })])
+ .then(([open, overdue]) => {
+ if (cancelled) return
+ const all = [...(open?.rechnungen || []), ...(overdue?.rechnungen || [])]
+ setState({ invoices: all, loading: false, error: '' })
+ })
+ .catch((err) => {
+ if (!cancelled) setState({ invoices: [], loading: false, error: err?.message || FALLBACK_ERROR })
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [active])
+
+ return state
+}
diff --git a/src/lib/financeView.js b/src/lib/financeView.js
index ece33fb..6d01b65 100644
--- a/src/lib/financeView.js
+++ b/src/lib/financeView.js
@@ -1,11 +1,16 @@
-// Reine Darstellungs-Helfer fuer die Bloecke der Finanzen-Seite (Status, Liquiditaet, Aufgaben, Vorschau, Steuer, Abos)
-// (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
+// Reine Darstellungs-Helfer fuer die Bloecke der Finanzen-Seite (Status, Liquiditaet, Aufgaben, Vorschau, Steuer, Abos,
+// Rechnungen, Buchungen, Zuordnen) (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
import {
+ AGE_GROUP_LABEL,
+ CATEGORY_LABEL,
INTERVAL_LABEL,
+ INVOICE_STATUS,
+ canInvoice,
dateDe,
euro,
euroWhole,
nextIntervalDate,
+ parseEuro,
percentDe,
signedEuro,
sinceLabel,
@@ -398,3 +403,526 @@ export function aboView(abos) {
}),
}
}
+
+// --- Rechnungen (Block 6) ----------------------------------------------------------------------------------
+
+/** Filter über der Rechnungsliste (Schlüssel = `status` der API); Standard ist „Offen“. */
+export const INVOICE_FILTERS = [
+ { key: 'offen', label: 'Offen' },
+ { key: 'ueberfaellig', label: 'Überfällig' },
+ { key: 'bezahlt', label: 'Bezahlt' },
+ { key: 'entwurf', label: 'Entwürfe' },
+ { key: 'alle', label: 'Alle' },
+]
+export const DEFAULT_INVOICE_FILTER = 'offen'
+
+const obj = (v) => (v && typeof v === 'object' ? v : {})
+const objects = (list) => (Array.isArray(list) ? list.filter((x) => x && typeof x === 'object') : [])
+const OPEN_INVOICE = new Set(['offen', 'ueberfaellig'])
+
+/**
+ * Altersgruppen der offenen Rechnungen als [{ key, label, count, sumText }] in der Reihenfolge nicht fällig, 1–30,
+ * 31–60, über 60 Tage (fehlende Gruppen zählen 0). Ohne Liste (kein Array) gibt es nichts anzuzeigen: [].
+ */
+export function ageGroupCells(altersgruppen) {
+ if (!Array.isArray(altersgruppen)) return []
+ return Object.keys(AGE_GROUP_LABEL).map((key) => {
+ const group = obj(altersgruppen.find((g) => g && g.key === key))
+ return {
+ key,
+ label: AGE_GROUP_LABEL[key],
+ count: isNum(group.anzahl) ? group.anzahl : 0,
+ sumText: euro(isNum(group.summeCents) ? group.summeCents : 0),
+ }
+ })
+}
+
+/**
+ * Aktionen einer Rechnung als [{ key, label, url? }] (key: hosted | pdf | send | resend | void | delete):
+ * Entwurf: Senden, Löschen; offen/überfällig: Zahlungsseite, PDF, Erinnerung senden, Stornieren; bezahlt: PDF.
+ * Abo-Rechnungen (source 'abo') verwaltet Stripe: höchstens Zahlungsseite und PDF. Adressen nur, wenn sie
+ * http(s) sind.
+ */
+export function invoiceActions(invoice) {
+ const inv = obj(invoice)
+ const abo = inv.source === 'abo'
+ const hosted = safeUrl(inv.hostedUrl)
+ const pdf = safeUrl(inv.pdfUrl)
+ const pdfAction = pdf ? [{ key: 'pdf', label: 'PDF', url: pdf }] : []
+ switch (inv.status) {
+ case 'entwurf':
+ return abo ? [] : [{ key: 'send', label: 'Senden' }, { key: 'delete', label: 'Löschen' }]
+ case 'offen':
+ case 'ueberfaellig':
+ return [
+ ...(hosted ? [{ key: 'hosted', label: 'Zahlungsseite', url: hosted }] : []),
+ ...pdfAction,
+ ...(abo ? [] : [{ key: 'resend', label: 'Erinnerung senden' }, { key: 'void', label: 'Stornieren' }]),
+ ]
+ default:
+ return pdfAction
+ }
+}
+
+/**
+ * Zeilen der Rechnungstabelle (Serverreihenfolge, neueste zuerst):
+ * { key, id, numberText, customerText, isAbo, createdText, dueText, totalText, remainingText, statusLabel,
+ * statusTone, statusNote, actions }. „Offen“ nur bei offen/überfällig; statusNote: „12 Tage überfällig“ bzw.
+ * „am 05.08.2026 · per Überweisung“ (außerhalb von Stripe bezahlt).
+ */
+export function invoiceRows(invoices) {
+ return objects(invoices).map((inv, index) => {
+ const status = has(INVOICE_STATUS, inv.status)
+ ? INVOICE_STATUS[inv.status]
+ : { label: text(inv.status) || 'unbekannt', tone: 'muted' }
+ let note = ''
+ if (inv.status === 'ueberfaellig' && isNum(inv.overdueDays) && inv.overdueDays > 0) {
+ note = `${inv.overdueDays} ${inv.overdueDays === 1 ? 'Tag' : 'Tage'} überfällig`
+ } else if (inv.status === 'bezahlt') {
+ note = [text(inv.paidDate) ? `am ${dateDe(inv.paidDate)}` : '', inv.outOfBand ? 'per Überweisung' : '']
+ .filter(Boolean)
+ .join(' · ')
+ }
+ return {
+ key: text(inv.id) || `row-${index}`,
+ id: text(inv.id),
+ numberText: text(inv.number) || 'ohne Nr.',
+ customerText: text(inv.customerName) || text(inv.customerEmail) || 'Unbekannter Kunde',
+ isAbo: inv.source === 'abo',
+ createdText: dateDe(inv.createdDate),
+ dueText: dateDe(inv.dueDate),
+ totalText: euro(inv.totalCents),
+ remainingText: OPEN_INVOICE.has(inv.status) ? euro(inv.remainingCents) : '–',
+ statusLabel: status.label,
+ statusTone: status.tone,
+ statusNote: note,
+ actions: invoiceActions(inv),
+ }
+ })
+}
+
+/** Rückfrage vor dem Löschen eines Entwurfs, dem Stornieren und der Erinnerung; '' für Aktionen ohne Rückfrage. */
+export function invoiceConfirmText(actionKey, invoice) {
+ const inv = obj(invoice)
+ const who = text(inv.customerName) || text(inv.customerEmail) || 'den Kunden'
+ const total = euro(inv.totalCents)
+ const number = text(inv.number) || 'ohne Nummer'
+ switch (actionKey) {
+ case 'delete':
+ return `Den Entwurf für ${who} über ${total} wirklich löschen? Das lässt sich nicht rückgängig machen.`
+ case 'void':
+ return `Die Rechnung ${number} an ${who} über ${total} wirklich stornieren? Das lässt sich nicht rückgängig machen.`
+ case 'resend':
+ return `Die Rechnung ${number} jetzt noch einmal an ${text(inv.customerEmail) || who} senden (Erinnerung)?`
+ default:
+ return ''
+ }
+}
+
+/** Erfolgsmeldung nach einer Rechnungsaktion (send | resend | void | delete | create-draft | create-send). */
+export function invoiceDoneMessage(actionKey, rechnung) {
+ const inv = obj(rechnung)
+ const number = text(inv.number)
+ const mail = text(inv.customerEmail)
+ const subject = number ? `Rechnung ${number}` : 'Die Rechnung'
+ const to = mail ? ` an ${mail}` : ''
+ switch (actionKey) {
+ case 'send':
+ case 'create-send':
+ return `${subject} wurde${to} gesendet.`
+ case 'resend':
+ return `Die Erinnerung zu ${number ? `Rechnung ${number}` : 'der Rechnung'} wurde${to} gesendet.`
+ case 'void':
+ return `${subject} wurde storniert.`
+ case 'delete':
+ return 'Der Entwurf wurde gelöscht.'
+ case 'create-draft':
+ return 'Der Entwurf wurde gespeichert.'
+ default:
+ return ''
+ }
+}
+
+/** Die Rechnung besteht schon bei Stripe, der Aufruf scheiterte danach (502 mit invoiceId). */
+export function isFinalizedNotSent(err) {
+ return Boolean(err) && err.status === 502 && Boolean(err.invoiceId)
+}
+
+// Der Server meldet auch „gesendet, konnte aber nicht gelesen werden“ (Rechnung ist raus, nur das Nachlesen scheiterte)
+const UNREADABLE = /nicht gelesen/i
+
+/**
+ * Meldung zu einem Fehler bei einer Rechnungsaktion. Bei 502 mit invoiceId (festgeschrieben, nicht versendet) der
+ * feststehende Text mit der Nummer; scheiterte nur das Nachlesen, gilt die genauere Meldung des Servers.
+ */
+export function invoiceFailureMessage(err) {
+ const message = text(err?.message) || 'Die Aktion ist fehlgeschlagen.'
+ if (isFinalizedNotSent(err) && !UNREADABLE.test(message)) {
+ const number = text(err.invoiceNumber)
+ return `${number ? `Rechnung ${number}` : 'Die Rechnung'} wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.`
+ }
+ return message
+}
+
+/** Welcher Filter die bei Stripe entstandene Rechnung zeigt: mit Nummer „Offen“, sonst (Entwurf) „Entwürfe“. */
+export function finalizedFilter(err) {
+ return text(err?.invoiceNumber) ? 'offen' : 'entwurf'
+}
+
+// --- Rechnung schreiben (Dialog) ---------------------------------------------------------------------------
+
+/** Grenzen der Rechnungs-API (Portal, stripeFinance.js); die Oberfläche prüft vorab, der Server entscheidet. */
+export const INVOICE_LIMITS = {
+ maxItems: 20,
+ maxDescription: 200,
+ maxQuantity: 999,
+ maxUnitCents: 10_000_000,
+ maxTotalCents: 99_999_999,
+ maxDays: 60,
+ defaultDays: 14,
+ maxMemo: 1000,
+}
+
+/** Menge als Text -> ganze Zahl 1..999, sonst null. */
+export function parseQuantity(value) {
+ const s = text(typeof value === 'number' ? String(value) : value)
+ if (!/^[0-9]{1,4}$/.test(s)) return null
+ const n = Number(s)
+ return n >= 1 && n <= INVOICE_LIMITS.maxQuantity ? n : null
+}
+
+/** Einzelpreis als Text („1.234,50“) -> Cent (1..100.000,00 €), sonst null. */
+export function parseUnitPrice(value) {
+ try {
+ const cents = parseEuro(typeof value === 'string' ? value : '')
+ return cents >= 1 && cents <= INVOICE_LIMITS.maxUnitCents ? cents : null
+ } catch {
+ return null
+ }
+}
+
+/** Zeilensumme einer Position { quantity, price } (Texte) in Cent; null, wenn Menge oder Preis nicht gültig sind. */
+export function lineTotalCents(item) {
+ const quantity = parseQuantity(item?.quantity)
+ const unit = parseUnitPrice(item?.price)
+ return quantity === null || unit === null ? null : quantity * unit
+}
+
+/** Summe aller gültigen Positionen in Cent (ungültige zählen 0). */
+export function invoiceTotalCents(items) {
+ return objects(items).reduce((sum, item) => sum + (lineTotalCents(item) ?? 0), 0)
+}
+
+/**
+ * Formular -> Anfrage. form = { customer, items: [{ description, quantity, price }], days, memo } (Texte).
+ * { ok: true, payload: { customerId, items: [{ description, quantity, unitAmountCents }], daysUntilDue, memo? },
+ * totalCents } oder { ok: false, error } mit der ersten Meldung. `senden` setzt der Aufrufer.
+ */
+export function validateInvoiceForm(form) {
+ const f = obj(form)
+ const fail = (error) => ({ ok: false, error })
+
+ if (!f.customer) return fail('Bitte einen Kunden wählen.')
+ const check = canInvoice(f.customer)
+ if (!check.ok) return fail(`Für ${customerName(f.customer) || 'diesen Kunden'} kann keine Rechnung geschrieben werden: ${check.reason}.`)
+
+ const items = objects(f.items)
+ if (items.length < 1 || items.length > INVOICE_LIMITS.maxItems) {
+ return fail(`Eine Rechnung braucht 1 bis ${INVOICE_LIMITS.maxItems} Positionen.`)
+ }
+ const lines = []
+ for (let i = 0; i < items.length; i += 1) {
+ const label = `Position ${i + 1}`
+ const description = text(items[i].description)
+ if (!description) return fail(`${label}: Bitte eine Beschreibung eingeben.`)
+ if (description.length > INVOICE_LIMITS.maxDescription) {
+ return fail(`${label}: Die Beschreibung darf höchstens ${INVOICE_LIMITS.maxDescription} Zeichen lang sein.`)
+ }
+ const quantity = parseQuantity(items[i].quantity)
+ if (quantity === null) return fail(`${label}: Die Menge muss eine ganze Zahl von 1 bis ${INVOICE_LIMITS.maxQuantity} sein.`)
+ const unit = parseUnitPrice(items[i].price)
+ if (unit === null) return fail(`${label}: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).`)
+ lines.push({ description, quantity, unitAmountCents: unit })
+ }
+ const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0)
+ if (totalCents > INVOICE_LIMITS.maxTotalCents) return fail('Die Rechnungssumme ist zu hoch (höchstens 999.999,99 €).')
+
+ const daysText = text(typeof f.days === 'number' ? String(f.days) : f.days)
+ const days = /^[0-9]{1,3}$/.test(daysText) ? Number(daysText) : null
+ if (days === null || days > INVOICE_LIMITS.maxDays) {
+ return fail(`Das Zahlungsziel muss eine ganze Zahl von 0 bis ${INVOICE_LIMITS.maxDays} Tagen sein.`)
+ }
+ const memo = text(f.memo)
+ if (memo.length > INVOICE_LIMITS.maxMemo) return fail(`Die Notiz darf höchstens ${INVOICE_LIMITS.maxMemo} Zeichen lang sein.`)
+
+ const payload = { customerId: f.customer.$id, items: lines, daysUntilDue: days }
+ if (memo) payload.memo = memo
+ return { ok: true, payload, totalCents }
+}
+
+/** Anzeigename eines Kunden (Appwrite-Dokument): name, sonst companyName. */
+export function customerName(customer) {
+ return text(customer?.name) || text(customer?.companyName)
+}
+
+/**
+ * Kundenauswahl: alle Kunden mit Namen als [{ id, name, label, email, ok, reason }] nach Namen sortiert (de).
+ * ok/reason nach canInvoice. label ist der Text im Feld und eindeutig (bei gleichem Namen mit E-Mail ergänzt).
+ */
+export function customerPickerOptions(customers) {
+ const named = objects(customers)
+ .map((c) => ({ c, name: customerName(c) }))
+ .filter((x) => x.name && text(x.c.$id))
+ .sort((a, b) => a.name.localeCompare(b.name, 'de', { sensitivity: 'base' }))
+ const count = new Map()
+ for (const { name } of named) count.set(name.toLowerCase(), (count.get(name.toLowerCase()) || 0) + 1)
+ const used = new Set()
+ return named.map(({ c, name }) => {
+ const email = text(c.email)
+ let label = count.get(name.toLowerCase()) > 1 ? `${name} (${email || c.$id})` : name
+ if (used.has(label.toLowerCase())) label = `${label} · ${String(c.$id).slice(-4)}`
+ used.add(label.toLowerCase())
+ const check = canInvoice(c)
+ return { id: c.$id, customer: c, name, label, email, ok: check.ok, reason: check.ok ? '' : check.reason }
+ })
+}
+
+/** Vorschläge für das Kundenfeld: nur Kunden mit ok, die den Text enthalten (Namensanfang zuerst), höchstens `max`. */
+export function customerSuggestions(options, input, max = 12) {
+ const needle = text(input).toLowerCase()
+ const list = objects(options).filter((o) => o.ok)
+ const hit = (o) => !needle || o.label.toLowerCase().includes(needle) || o.email.toLowerCase().includes(needle)
+ const starts = list.filter((o) => hit(o) && o.label.toLowerCase().startsWith(needle))
+ const rest = list.filter((o) => hit(o) && !o.label.toLowerCase().startsWith(needle))
+ return [...starts, ...rest].slice(0, max).map((o) => ({ value: o.label, hint: o.email }))
+}
+
+/**
+ * Was im Kundenfeld steht: { state: 'empty' | 'ok' | 'blocked' | 'unknown', option }. blocked = Kunde gefunden, aber
+ * ohne Rechnung möglich (option.reason nennt den Grund); unknown = Text passt zu keinem Kunden.
+ */
+export function resolveCustomerText(options, input) {
+ const value = text(input).toLowerCase()
+ if (!value) return { state: 'empty', option: null }
+ const option = objects(options).find((o) => o.label.toLowerCase() === value) || null
+ if (!option) return { state: 'unknown', option: null }
+ return { state: option.ok ? 'ok' : 'blocked', option }
+}
+
+// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
+
+const RANK = { hoch: 0, mittel: 1, niedrig: 2 }
+const dayOf = (tx) => text(tx.bookingDate) || text(tx.valueDate)
+
+/** Buchungen neueste zuerst (Buchungsdatum, sonst Wertstellung); gleiche Tage behalten die Reihenfolge des Servers. */
+export function sortTransactions(list) {
+ return objects(list)
+ .map((tx, index) => ({ tx, index }))
+ .sort((a, b) => {
+ const da = dayOf(a.tx)
+ const db = dayOf(b.tx)
+ if (da === db) return a.index - b.index
+ return da < db ? 1 : -1
+ })
+ .map((x) => x.tx)
+}
+
+/** Stärkster Vorschlag (hoch vor mittel vor niedrig, sonst Serverreihenfolge) oder null. */
+export function strongestSuggestion(vorschlaege) {
+ const list = objects(vorschlaege)
+ let best = null
+ for (const s of list) {
+ if (!best || (RANK[s.confidence] ?? 9) < (RANK[best.confidence] ?? 9)) best = s
+ }
+ return best
+}
+
+/** „Vorschlag: Rechnung WK-0005 (hoch)“ / „Vorschlag: Stripe-Auszahlung (hoch) · Betrag weicht ab“; '' ohne Vorschlag. */
+export function suggestionText(suggestion) {
+ if (!suggestion || typeof suggestion !== 'object') return ''
+ const what = suggestion.kind === 'payout' ? 'Stripe-Auszahlung' : `Rechnung ${text(suggestion.number) || text(suggestion.id)}`
+ const level = has(RANK, suggestion.confidence) ? ` (${suggestion.confidence})` : ''
+ return `Vorschlag: ${what}${level}${suggestion.amountDiffers ? ' · Betrag weicht ab' : ''}`
+}
+
+/** Text der Zuordnung: „Offen“, „Rechnung WK-0005“, „Stripe-Auszahlung“, „Fixkosten“, „Einnahme ohne Stripe-Rechnung“ … */
+export function assignmentText(tx) {
+ const t = obj(tx)
+ const category = text(t.category)
+ if (category === 'rechnung') return `Rechnung ${text(t.matchedInvoiceNumber) || text(t.matchedInvoiceId) || ''}`.trim()
+ if (has(CATEGORY_LABEL, category)) return CATEGORY_LABEL[category]
+ return category || CATEGORY_LABEL['']
+}
+
+/**
+ * Zeilen der Buchungstabelle in der Reihenfolge von sortTransactions:
+ * { key, id, tx, dateText, counterparty, remittance, amountText, incoming, pending, assignmentText, assigned, note,
+ * suggestionText (leer bei zugeordneten und vorgemerkten Buchungen), action: 'assign' | 'change' | null, actionLabel }.
+ * Zuordnen für jede offene Buchung, „Ändern“ für zugeordnete außer Rechnungen (sie lassen sich nicht lösen).
+ */
+export function transactionRows(buchungen) {
+ return sortTransactions(buchungen).map((tx, index) => {
+ const category = text(tx.category)
+ const assigned = Boolean(category)
+ let action = null
+ if (!assigned) action = 'assign'
+ else if (category !== 'rechnung') action = 'change'
+ return {
+ key: text(tx.$id) || `tx-${index}`,
+ id: text(tx.$id),
+ tx,
+ dateText: dateDe(dayOf(tx)),
+ counterparty: text(tx.counterpartyName) || text(tx.counterpartyIban) || 'Unbekannt',
+ remittance: text(tx.remittance),
+ amountText: signedEuro(tx.amountCents),
+ incoming: isNum(tx.amountCents) && tx.amountCents > 0,
+ pending: tx.status === 'PDNG',
+ assignmentText: assignmentText(tx),
+ assigned,
+ note: text(tx.note),
+ // Vorgemerkte Buchungen lassen sich keiner Rechnung zuordnen: kein Vorschlag
+ suggestionText: assigned || tx.status === 'PDNG' ? '' : suggestionText(strongestSuggestion(tx.vorschlaege)),
+ action,
+ actionLabel: action === 'assign' ? 'Zuordnen' : action === 'change' ? 'Ändern' : '',
+ }
+ })
+}
+
+// --- Zuordnen (Dialog) -------------------------------------------------------------------------------------
+
+export const PENDING_ASSIGN_HINT = 'Vorgemerkte Buchungen können erst nach der Buchung einer Rechnung zugeordnet werden.'
+
+/** Bestätigungstext vor dem Bezahlt-Markieren (nicht umkehrbar). */
+export function invoiceAssignConfirmText(number) {
+ return `Die Rechnung ${number} wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.`
+}
+
+const fixedCostLabel = (f) => `${text(f.name) || 'Fixkosten'} · ${euro(f.amountCents)} ${INTERVAL_LABEL[f.interval] || ''}`.trim()
+
+/**
+ * Auswahl im Zuordnen-Dialog zu einer Buchung:
+ * { incoming, outgoing, pending, assigned, currentText, canUnassign, hint, suggestions, invoices, fixedCosts,
+ * categories, byKey }. Jede Option: { key, type ('invoice'|'payout'|'fixedCost'|'category'), label, detail,
+ * disabled, disabledReason, number, body } mit body = Anfrage an die API (ohne Notiz).
+ * - zugeordnete Buchung: keine Optionen, nur currentText (und canUnassign außer bei Rechnungen)
+ * - vorgemerkt (PDNG): nur Fixkosten (Ausgang) und Kategorien, mit Hinweis
+ * - Eingang: Vorschläge des Servers, andere offene Rechnungen, Kategorien (auch „Einnahme ohne Stripe-Rechnung“)
+ * - Ausgang: Fixkosten (aktive), Privat/Umbuchung, Sonstiges
+ * Rechnungen, deren offener Betrag vom Betrag der Buchung abweicht, sind nicht wählbar (Teilzahlungen in Stripe).
+ */
+export function assignModel(tx, { invoices = [], fixedCosts = [] } = {}) {
+ const t = obj(tx)
+ const amount = t.amountCents
+ const incoming = isNum(amount) && amount > 0
+ const outgoing = isNum(amount) && amount < 0
+ const pending = t.status === 'PDNG'
+ const category = text(t.category)
+ const assigned = Boolean(category)
+ const model = {
+ incoming,
+ outgoing,
+ pending,
+ assigned,
+ currentText: assigned ? assignmentText(t) : '',
+ canUnassign: assigned && category !== 'rechnung',
+ hint: pending && !assigned ? PENDING_ASSIGN_HINT : '',
+ suggestions: [],
+ invoices: [],
+ fixedCosts: [],
+ categories: [],
+ byKey: {},
+ }
+ if (assigned) return model
+
+ const open = objects(invoices).filter((inv) => OPEN_INVOICE.has(inv.status) && text(inv.id))
+ const invoiceOption = (inv, extra = {}) => {
+ const differs = isNum(amount) && inv.remainingCents !== amount
+ return {
+ key: `invoice:${inv.id}`,
+ type: 'invoice',
+ label: `${text(inv.number) || inv.id} · ${text(inv.customerName) || text(inv.customerEmail) || 'Unbekannter Kunde'} · ${euro(inv.remainingCents)} offen · fällig ${dateDe(inv.dueDate)}`,
+ detail: '',
+ disabled: differs,
+ disabledReason: differs ? `Betrag weicht ab (offen: ${euro(inv.remainingCents)})` : '',
+ number: text(inv.number),
+ body: { invoiceId: inv.id },
+ ...extra,
+ }
+ }
+
+ if (incoming && !pending) {
+ const seen = new Set()
+ for (const s of objects(t.vorschlaege)) {
+ if (s.kind === 'payout' && text(s.id)) {
+ model.suggestions.push({
+ key: `payout:${s.id}`,
+ type: 'payout',
+ label: 'Stripe-Auszahlung',
+ detail: text(s.reason),
+ confidence: s.confidence,
+ disabled: false,
+ disabledReason: '',
+ number: '',
+ body: { payoutId: s.id },
+ })
+ } else if (s.kind === 'invoice' && text(s.id) && !seen.has(s.id)) {
+ seen.add(s.id)
+ const known = open.find((inv) => inv.id === s.id)
+ const differs = s.amountDiffers === true || (known ? known.remainingCents !== amount : false)
+ const number = text(s.number) || text(known?.number)
+ model.suggestions.push({
+ key: `invoice:${s.id}`,
+ type: 'invoice',
+ label: `Rechnung ${number || s.id}${known ? ` · ${text(known.customerName) || text(known.customerEmail)}` : ''}`,
+ detail: [text(s.reason), known ? `${euro(known.remainingCents)} offen` : ''].filter(Boolean).join(' · '),
+ confidence: s.confidence,
+ disabled: differs,
+ disabledReason: differs ? 'Betrag weicht ab, Teilzahlungen bitte direkt in Stripe erfassen' : '',
+ number,
+ body: { invoiceId: s.id },
+ })
+ }
+ }
+ model.invoices = open.filter((inv) => !seen.has(inv.id)).map((inv) => invoiceOption(inv))
+ }
+
+ if (outgoing) {
+ model.fixedCosts = objects(fixedCosts)
+ .filter((f) => f.active !== false && text(f.$id))
+ .map((f) => ({
+ key: `fixedCost:${f.$id}`,
+ type: 'fixedCost',
+ label: fixedCostLabel(f),
+ detail: '',
+ disabled: false,
+ disabledReason: '',
+ number: '',
+ body: { fixedCostId: f.$id },
+ }))
+ }
+
+ const categories = [...(incoming ? ['rechnung-extern'] : []), 'privat', 'sonstiges']
+ model.categories = categories.map((value) => ({
+ key: `category:${value}`,
+ type: 'category',
+ label: CATEGORY_LABEL[value],
+ detail: '',
+ disabled: false,
+ disabledReason: '',
+ number: '',
+ body: { category: value },
+ }))
+
+ for (const option of [...model.suggestions, ...model.invoices, ...model.fixedCosts, ...model.categories]) {
+ model.byKey[option.key] = option
+ }
+ return model
+}
+
+/** Anfrage-Body für assignTransaction: Ziel der Option plus Notiz (nur wenn nicht leer; höchstens 500 Zeichen). */
+export function assignBody(option, note) {
+ const body = { ...obj(option).body }
+ const n = text(note)
+ if (n) body.note = n
+ return body
+}
diff --git a/src/pages/FinancePage.jsx b/src/pages/FinancePage.jsx
index 04fa968..e5534b4 100644
--- a/src/pages/FinancePage.jsx
+++ b/src/pages/FinancePage.jsx
@@ -1,4 +1,4 @@
-import { useCallback, useRef, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FaSpinner } from 'react-icons/fa6'
import { PageHeader, Card, Button } from '../components/ui'
@@ -8,17 +8,71 @@ import TodoList from '../components/finance/TodoList'
import ForecastTable from '../components/finance/ForecastTable'
import TaxCard from '../components/finance/TaxCard'
import AboCard from '../components/finance/AboCard'
+import InvoiceTable from '../components/finance/InvoiceTable'
+import TransactionTable from '../components/finance/TransactionTable'
+import AssignDialog from '../components/finance/AssignDialog'
import { useFinanceOverview } from '../hooks/useFinanceOverview'
+import { useFinanceTransactions } from '../hooks/useFinanceTransactions'
+import { useOpenInvoices } from '../hooks/useOpenInvoices'
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
import { planTodoAction, safeUrl } from '../lib/financeView'
export default function FinancePage() {
const navigate = useNavigate()
const { data, loading, error, reload } = useFinanceOverview()
+ const txs = useFinanceTransactions()
+ const reloadTransactions = txs.reload
const [refreshing, setRefreshing] = useState(false)
- // Buchung, die gerade zugeordnet werden soll; den Dialog dazu zeigt Block 7 (Task 5)
+ // Buchung, die gerade zugeordnet werden soll (Knopf in Block 7 oder Aufgabe „Zahlungseingang zuordnen“)
const [assignTxId, setAssignTxId] = useState(null)
+ const [assignNotice, setAssignNotice] = useState('')
const refreshLock = useRef(false)
+ const retriedRef = useRef(null)
+
+ const assignTx = assignTxId ? txs.transactions.find((t) => t.$id === assignTxId) || null : null
+ // Offene Rechnungen braucht nur ein gebuchter, noch nicht zugeordneter Eingang
+ const needsInvoices = Boolean(assignTx && !assignTx.category && assignTx.status !== 'PDNG' && assignTx.amountCents > 0)
+ const openInvoices = useOpenInvoices(needsInvoices)
+
+ // Übersicht und Buchungen zusammen neu laden (nach Zuordnen, Rechnungsänderungen und Bankabruf)
+ const reloadAll = useCallback(() => Promise.all([reload(), reloadTransactions()]), [reload, reloadTransactions])
+
+ // Eine Aufgabe kann eine Buchung nennen, die in der geladenen Liste fehlt (neu abgerufen): einmal nachladen,
+ // dann melden statt still nichts zu tun
+ useEffect(() => {
+ if (!assignTxId || assignTx || txs.loading) return
+ if (retriedRef.current !== assignTxId) {
+ retriedRef.current = assignTxId
+ reloadTransactions()
+ return
+ }
+ setAssignNotice(
+ txs.error
+ ? `Die Buchungen konnten nicht geladen werden: ${txs.error}`
+ : 'Die Buchung wurde nicht gefunden. Möglicherweise ist sie schon zugeordnet.'
+ )
+ setAssignTxId(null)
+ }, [assignTxId, assignTx, txs.loading, txs.error, reloadTransactions])
+
+ const openAssign = useCallback((id) => {
+ setAssignNotice('')
+ retriedRef.current = null
+ setAssignTxId(id)
+ }, [])
+
+ const handleAssignDone = useCallback(() => {
+ setAssignTxId(null)
+ return reloadAll()
+ }, [reloadAll])
+
+ const handleAssignClose = useCallback(
+ (info) => {
+ setAssignTxId(null)
+ // Nach einem Fehler kann sich der Stand geändert haben (z. B. „Bereits zugeordnet“)
+ if (info?.refresh) reloadAll()
+ },
+ [reloadAll]
+ )
// 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.
@@ -28,12 +82,12 @@ export default function FinancePage() {
setRefreshing(true)
try {
await refreshBank()
- await reload()
+ await reloadAll()
} finally {
refreshLock.current = false
setRefreshing(false)
}
- }, [reload])
+ }, [reloadAll])
const handleAction = useCallback(
async (aktion) => {
@@ -59,7 +113,7 @@ export default function FinancePage() {
})
return
case 'assign':
- setAssignTxId(plan.transactionId)
+ openAssign(plan.transactionId)
return
case 'navigate':
navigate(plan.to)
@@ -73,7 +127,7 @@ export default function FinancePage() {
throw new Error(plan.message)
}
},
- [navigate, refresh, reload]
+ [navigate, openAssign, refresh, reload]
)
let body
@@ -115,6 +169,16 @@ export default function FinancePage() {
+
+ openAssign(tx.$id)}
+ />
)
}
@@ -122,7 +186,24 @@ export default function FinancePage() {
return (
+ {assignNotice && (
+
+ {assignNotice}{' '}
+
+
+ )}
{body}
+ {assignTx && (
+
+ )}
)
}
diff --git a/src/styles/global.css b/src/styles/global.css
index 8eea7d0..f8c964f 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1282,3 +1282,152 @@ textarea.form-control {
.fin-abo:last-child { border-bottom: none; }
.fin-abo-customer { font-weight: 700; overflow-wrap: anywhere; }
.fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px var(--sp-3); font-size: 13px; }
+
+/* Block 6 und 7: Rechnungen und Buchungen (Tabellen; in einer schmalen Karte wird jede Zeile ein Raster) */
+.fin-block { scroll-margin-top: var(--sp-4); }
+.fin-invoices-card, .fin-tx-card { container-type: inline-size; overflow: hidden; }
+.fin-toolbar { padding: var(--sp-3) var(--sp-4); }
+.fin-toolbar .seg { flex-wrap: wrap; max-width: 100%; }
+.fin-notice { padding: var(--sp-3) var(--sp-4) var(--sp-2); }
+.fin-load-error { display: grid; gap: var(--sp-3); justify-items: start; padding: var(--sp-4); }
+.fin-page-notice { margin-bottom: var(--sp-4); }
+.fin-ages {
+ list-style: none; margin: 0; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border);
+ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3);
+}
+.fin-age { display: grid; gap: 2px; min-width: 0; }
+.fin-age-label { color: var(--text-muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
+.fin-age-count { font-size: 13px; color: var(--text-muted); }
+.fin-age-sum { text-align: left; font-size: 18px; font-weight: 800; }
+.fin-pager { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
+.fin-pager-info { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
+
+.fin-invoices th, .fin-invoices td, .fin-tx th, .fin-tx td { padding: 9px 10px; vertical-align: top; }
+.fin-invoices th.fin-amount, .fin-tx th.fin-amount { text-align: right; }
+.fin-invoices th:first-child, .fin-invoices td:first-child, .fin-tx th:first-child, .fin-tx td:first-child { padding-left: var(--sp-4); }
+.fin-invoices th:last-child, .fin-invoices td:last-child, .fin-tx th:last-child, .fin-tx td:last-child { padding-right: var(--sp-4); }
+.fin-invoices tbody tr:last-child td, .fin-tx tbody tr:last-child td { border-bottom: none; }
+.fin-inv-number { font-weight: 700; white-space: nowrap; }
+.fin-inv-customer { overflow-wrap: anywhere; }
+.fin-inv-created, .fin-inv-due, .fin-tx-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
+.fin-inv-statusbox { display: grid; gap: 3px; justify-items: start; }
+.fin-inv-note { font-size: 12px; }
+.fin-actions { display: flex; flex-wrap: wrap; gap: 6px; }
+.fin-tx-counterparty { font-weight: 600; overflow-wrap: anywhere; }
+.fin-tx-remittance {
+ font-size: 13px; overflow-wrap: anywhere;
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+}
+.fin-tx-amount { font-weight: 700; }
+.fin-tx-badges { display: flex; flex-wrap: wrap; gap: 4px; }
+.fin-tx-assign .badge { white-space: normal; }
+.fin-tx-suggestion, .fin-tx-note { font-size: 12px; margin-top: 4px; overflow-wrap: anywhere; }
+.fin-tx-action { white-space: nowrap; text-align: right; }
+@container (max-width: 860px) {
+ .fin-ages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .fin-invoices thead, .fin-tx thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+ .fin-invoices, .fin-invoices tbody, .fin-tx, .fin-tx tbody { display: block; }
+ .fin-invoices tr {
+ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px var(--sp-4); align-items: baseline;
+ grid-template-areas: "number status" "customer customer" "created due" "total remaining" "actions actions";
+ padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
+ }
+ .fin-invoices tbody tr:last-child { border-bottom: none; }
+ .fin-invoices td, .fin-invoices td:first-child, .fin-invoices td:last-child, .fin-tx td, .fin-tx td:first-child, .fin-tx td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
+ .fin-invoices tbody tr:hover td, .fin-tx tbody tr:hover td { background: transparent; }
+ .fin-inv-number { grid-area: number; }
+ .fin-inv-status { grid-area: status; }
+ .fin-inv-statusbox { justify-items: end; text-align: right; }
+ .fin-inv-customer { grid-area: customer; }
+ .fin-inv-created { grid-area: created; }
+ .fin-inv-due { grid-area: due; }
+ .fin-inv-total { grid-area: total; }
+ .fin-inv-remaining { grid-area: remaining; }
+ .fin-inv-actions { grid-area: actions; padding-top: 4px; }
+ .fin-invoices td[data-label] { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0 var(--sp-2); font-size: 13px; }
+ .fin-invoices td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-weight: 500; }
+}
+@container (max-width: 640px) {
+ .fin-tx tr {
+ display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
+ grid-template-areas: "date amount" "what what" "assign action";
+ padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
+ }
+ .fin-tx tbody tr:last-child { border-bottom: none; }
+ .fin-tx-date { grid-area: date; }
+ .fin-tx-amount { grid-area: amount; }
+ .fin-tx-what { grid-area: what; }
+ .fin-tx-assign { grid-area: assign; }
+ .fin-tx-action { grid-area: action; align-self: start; }
+}
+
+/* Dialoge: Rechnung schreiben, Buchung zuordnen (auf dem dunklen Overlay) */
+.fin-close { background: none; border: 0; padding: 0; line-height: 1; font-size: 32px; top: 16px; right: 24px; z-index: 1; }
+.fin-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 4px; }
+.fin-close:disabled { opacity: 0.4; cursor: not-allowed; }
+.fin-overlay { background: rgba(6, 9, 14, 0.98); }
+.fin-dialog { max-width: 760px; }
+.fin-dialog:focus { outline: none; }
+.fin-dialog-error {
+ margin-bottom: var(--sp-4); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-sm);
+ border: 1px solid var(--danger); background: rgba(239, 68, 68, 0.16); color: #fecaca; overflow-wrap: anywhere;
+}
+.fin-dialog-warn { margin: 6px 0 0; font-size: 13px; color: #fbbf24; overflow-wrap: anywhere; }
+.fin-dialog-sub { margin: 6px 0 0; font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
+.fin-fixed-customer { margin: 0; overflow-wrap: anywhere; }
+.fin-items { border: 0; padding: 0; margin: 0 0 var(--sp-4); min-width: 0; }
+.fin-items legend { padding: 0; }
+.fin-item {
+ display: grid; grid-template-columns: minmax(0, 1fr) 90px 130px 120px 38px; gap: 8px; align-items: end; margin-bottom: 8px;
+}
+.fin-item-field { min-width: 0; }
+.fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-muted); }
+.fin-item ~ .fin-item .fin-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.fin-item .form-control.fin-amount { text-align: right; }
+.fin-item-total { display: block; padding: 10px 0; font-weight: 600; }
+.fin-icon-btn {
+ width: 38px; height: 40px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
+ background: transparent; border: 1px solid var(--border-strong); border-radius: 6px; color: var(--text-muted);
+}
+.fin-icon-btn:hover:not(:disabled) { color: #f87171; border-color: #f87171; }
+.fin-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
+.fin-icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); margin-top: var(--sp-3); }
+.fin-total { display: flex; align-items: baseline; gap: var(--sp-4); font-size: 18px; font-weight: 700; }
+.fin-dialog-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--sp-4); }
+.fin-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--sp-3); margin-top: var(--sp-4); }
+.fin-tx-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border-strong); border-radius: var(--r-sm); }
+.fin-tx-facts > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
+.fin-tx-facts dt { color: var(--text-muted); font-size: 13px; }
+.fin-tx-facts dd { margin: 0; overflow-wrap: anywhere; }
+.fin-tx-facts .fin-tx-facts-amount { text-align: left; font-weight: 700; }
+.fin-assigned { margin: 0 0 var(--sp-2); }
+.fin-choices { border: 0; padding: 0; margin: 0 0 var(--sp-4); display: grid; gap: 8px; min-width: 0; }
+.fin-choices legend { padding: 0; margin-bottom: 4px; }
+.fin-choice {
+ display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; cursor: pointer;
+ border: 1px solid var(--border-strong); border-radius: var(--r-sm);
+}
+.fin-choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
+.fin-choice:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
+.fin-choice input { margin-top: 4px; accent-color: var(--accent); }
+.fin-choice-off { opacity: 0.65; cursor: not-allowed; }
+.fin-choice-text { display: grid; gap: 2px; min-width: 0; }
+.fin-choice-label { font-weight: 600; overflow-wrap: anywhere; }
+.fin-choice-detail { font-size: 13px; overflow-wrap: anywhere; }
+.fin-confirm { margin-top: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--warn); border-radius: var(--r-sm); background: rgba(245, 158, 11, 0.1); }
+.fin-confirm:focus { outline: 2px solid var(--warn); outline-offset: 2px; }
+.fin-confirm p { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
+@media (max-width: 640px) {
+ .fin-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) 38px; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
+ .fin-item-desc { grid-area: desc; }
+ .fin-item-qty { grid-area: qty; }
+ .fin-item-price { grid-area: price; }
+ .fin-item-sum { grid-area: sum; text-align: right; }
+ .fin-item-remove { grid-area: remove; }
+ .fin-item ~ .fin-item .fin-item-label { position: static; width: auto; height: auto; margin: 0 0 4px; overflow: visible; clip: auto; white-space: normal; }
+ .fin-dialog-row { grid-template-columns: minmax(0, 1fr); }
+ .fin-tx-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
+ .fin-dialog-actions .ui-btn { flex: 1 1 auto; }
+ .fin-toolbar .seg button { padding: 6px 9px; }
+}
diff --git a/tests/unit/financeView.test.mjs b/tests/unit/financeView.test.mjs
index 68d4c99..ef4ef64 100644
--- a/tests/unit/financeView.test.mjs
+++ b/tests/unit/financeView.test.mjs
@@ -23,6 +23,35 @@ const {
aboView,
productLabel,
aboStatus,
+ INVOICE_FILTERS,
+ DEFAULT_INVOICE_FILTER,
+ INVOICE_LIMITS,
+ PENDING_ASSIGN_HINT,
+ ageGroupCells,
+ invoiceActions,
+ invoiceRows,
+ invoiceConfirmText,
+ invoiceDoneMessage,
+ isFinalizedNotSent,
+ finalizedFilter,
+ invoiceFailureMessage,
+ parseQuantity,
+ parseUnitPrice,
+ lineTotalCents,
+ invoiceTotalCents,
+ validateInvoiceForm,
+ customerName,
+ customerPickerOptions,
+ customerSuggestions,
+ resolveCustomerText,
+ sortTransactions,
+ strongestSuggestion,
+ suggestionText,
+ assignmentText,
+ transactionRows,
+ invoiceAssignConfirmText,
+ assignModel,
+ assignBody,
} = await loadModule('src/lib/financeView.js')
const MINUS = '−'
@@ -607,3 +636,491 @@ test('productLabel und aboStatus', () => {
assert.deepEqual(aboStatus('weird'), { label: 'weird', tone: 'muted' })
assert.deepEqual(aboStatus(''), { label: 'unbekannt', tone: 'muted' })
})
+
+// --- Rechnungen (Block 6) ----------------------------------------------------------------------------------
+
+const INV = (over = {}) => ({
+ id: 'in_1', number: 'WK-0006', status: 'ueberfaellig', customerName: 'Kunde Zwei', customerEmail: 'k@example.invalid',
+ webklarCustomerId: 'c2', createdDate: '2026-09-01', dueDate: '2026-09-21', totalCents: 5000, remainingCents: 5000,
+ paidCents: 0, paidDate: null, overdueDays: 10, source: 'projekt', hostedUrl: 'https://pay.stripe.test/i/1',
+ pdfUrl: 'https://pay.stripe.test/i/1/pdf', outOfBand: false, bankTransactionId: '', ...over,
+})
+
+test('INVOICE_FILTERS: Reihenfolge Offen, Überfällig, Bezahlt, Entwürfe, Alle; Standard Offen', () => {
+ assert.deepEqual(INVOICE_FILTERS.map((f) => f.label), ['Offen', 'Überfällig', 'Bezahlt', 'Entwürfe', 'Alle'])
+ assert.deepEqual(INVOICE_FILTERS.map((f) => f.key), ['offen', 'ueberfaellig', 'bezahlt', 'entwurf', 'alle'])
+ assert.equal(DEFAULT_INVOICE_FILTER, 'offen')
+})
+
+test('ageGroupCells: vier Gruppen in fester Reihenfolge, fehlende zählen 0', () => {
+ const cells = ageGroupCells([
+ { key: '1-30', anzahl: 2, summeCents: 12345 },
+ { key: 'nicht-faellig', anzahl: 1, summeCents: 16879 },
+ ])
+ assert.deepEqual(cells.map((c) => c.key), ['nicht-faellig', '1-30', '31-60', 'ueber-60'])
+ assert.deepEqual(cells.map((c) => c.label), ['Nicht fällig', '1–30 Tage', '31–60 Tage', 'Über 60 Tage'])
+ assert.deepEqual(cells.map((c) => c.count), [1, 2, 0, 0])
+ assert.deepEqual(cells.map((c) => c.sumText), ['168,79 €', '123,45 €', '0,00 €', '0,00 €'])
+ assert.deepEqual(ageGroupCells(null), [])
+ assert.deepEqual(ageGroupCells(undefined), [])
+ assert.equal(ageGroupCells([]).length, 4)
+})
+
+test('invoiceActions: Entwurf bekommt Senden und Löschen, keine Adressen', () => {
+ const a = invoiceActions(INV({ status: 'entwurf', number: '', hostedUrl: null, pdfUrl: null }))
+ assert.deepEqual(a.map((x) => x.key), ['send', 'delete'])
+ assert.deepEqual(a.map((x) => x.label), ['Senden', 'Löschen'])
+})
+
+test('invoiceActions: offen und überfällig (Projekt): Zahlungsseite, PDF, Erinnerung senden, Stornieren', () => {
+ for (const status of ['offen', 'ueberfaellig']) {
+ const a = invoiceActions(INV({ status }))
+ assert.deepEqual(a.map((x) => x.label), ['Zahlungsseite', 'PDF', 'Erinnerung senden', 'Stornieren'])
+ assert.equal(a[0].url, 'https://pay.stripe.test/i/1')
+ assert.equal(a[1].url, 'https://pay.stripe.test/i/1/pdf')
+ }
+})
+
+test('invoiceActions: Abo-Rechnungen nur Zahlungsseite und PDF, nie Senden/Stornieren/Löschen', () => {
+ assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'offen' })).map((x) => x.key), ['hosted', 'pdf'])
+ assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'ueberfaellig' })).map((x) => x.key), ['hosted', 'pdf'])
+ assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'entwurf' })), [])
+ assert.deepEqual(invoiceActions(INV({ source: 'abo', status: 'bezahlt' })).map((x) => x.key), ['pdf'])
+})
+
+test('invoiceActions: bezahlt nur PDF; unsichere Adressen fallen weg', () => {
+ assert.deepEqual(invoiceActions(INV({ status: 'bezahlt' })).map((x) => x.key), ['pdf'])
+ assert.deepEqual(invoiceActions(INV({ status: 'storniert' })).map((x) => x.key), ['pdf'])
+ const unsafe = invoiceActions(INV({ status: 'offen', hostedUrl: 'javascript:alert(1)', pdfUrl: '' }))
+ assert.deepEqual(unsafe.map((x) => x.key), ['resend', 'void'])
+ assert.deepEqual(invoiceActions(null), [])
+})
+
+test('invoiceRows: Spalten und Texte', () => {
+ const [r] = invoiceRows([INV()])
+ assert.equal(r.numberText, 'WK-0006')
+ assert.equal(r.customerText, 'Kunde Zwei')
+ assert.equal(r.createdText, '01.09.2026')
+ assert.equal(r.dueText, '21.09.2026')
+ assert.equal(r.totalText, '50,00 €')
+ assert.equal(r.remainingText, '50,00 €')
+ assert.equal(r.statusLabel, 'Überfällig')
+ assert.equal(r.statusTone, 'warn')
+ assert.equal(r.statusNote, '10 Tage überfällig')
+ assert.equal(r.isAbo, false)
+})
+
+test('invoiceRows: Entwurf ohne Nummer, Bezahlt per Überweisung, Offen nur bei offen/überfällig', () => {
+ const [draft, paid, paidStripe, open, one] = invoiceRows([
+ INV({ status: 'entwurf', number: '', dueDate: null, hostedUrl: null, pdfUrl: null, overdueDays: 0, remainingCents: 3000, totalCents: 3000 }),
+ INV({ status: 'bezahlt', paidDate: '2026-08-05', outOfBand: true, remainingCents: 0, totalCents: 16879 }),
+ INV({ status: 'bezahlt', paidDate: '2026-08-06', outOfBand: false, remainingCents: 0 }),
+ INV({ status: 'offen', overdueDays: 0, remainingCents: 1234 }),
+ INV({ overdueDays: 1 }),
+ ])
+ assert.equal(draft.numberText, 'ohne Nr.')
+ assert.equal(draft.dueText, '–')
+ assert.equal(draft.remainingText, '–')
+ assert.equal(draft.statusLabel, 'Entwurf')
+ assert.equal(paid.statusNote, 'am 05.08.2026 · per Überweisung')
+ assert.equal(paid.remainingText, '–')
+ assert.equal(paidStripe.statusNote, 'am 06.08.2026')
+ assert.equal(open.statusNote, '')
+ assert.equal(open.remainingText, '12,34 €')
+ assert.equal(one.statusNote, '1 Tag überfällig')
+})
+
+test('invoiceRows: fehlende Namen, Abo-Kennzeichen, kaputte Einträge', () => {
+ const rows = invoiceRows([INV({ customerName: '', customerEmail: 'a@b.invalid', source: 'abo' }), INV({ customerName: '', customerEmail: '' }), null, 5])
+ assert.equal(rows.length, 2)
+ assert.equal(rows[0].customerText, 'a@b.invalid')
+ assert.equal(rows[0].isAbo, true)
+ assert.equal(rows[1].customerText, 'Unbekannter Kunde')
+ assert.deepEqual(invoiceRows(undefined), [])
+})
+
+test('invoiceConfirmText: Löschen, Stornieren, Erinnerung; sonst leer', () => {
+ const inv = INV({ status: 'offen' })
+ assert.equal(invoiceConfirmText('delete', inv), 'Den Entwurf für Kunde Zwei über 50,00 € wirklich löschen? Das lässt sich nicht rückgängig machen.')
+ assert.equal(invoiceConfirmText('void', inv), 'Die Rechnung WK-0006 an Kunde Zwei über 50,00 € wirklich stornieren? Das lässt sich nicht rückgängig machen.')
+ assert.equal(invoiceConfirmText('resend', inv), 'Die Rechnung WK-0006 jetzt noch einmal an k@example.invalid senden (Erinnerung)?')
+ assert.equal(invoiceConfirmText('send', inv), '')
+ assert.equal(invoiceConfirmText('pdf', inv), '')
+})
+
+test('invoiceDoneMessage', () => {
+ const r = { number: 'WK-0007', customerEmail: 'k@example.invalid' }
+ assert.equal(invoiceDoneMessage('send', r), 'Rechnung WK-0007 wurde an k@example.invalid gesendet.')
+ assert.equal(invoiceDoneMessage('create-send', r), 'Rechnung WK-0007 wurde an k@example.invalid gesendet.')
+ assert.equal(invoiceDoneMessage('resend', r), 'Die Erinnerung zu Rechnung WK-0007 wurde an k@example.invalid gesendet.')
+ assert.equal(invoiceDoneMessage('void', r), 'Rechnung WK-0007 wurde storniert.')
+ assert.equal(invoiceDoneMessage('delete'), 'Der Entwurf wurde gelöscht.')
+ assert.equal(invoiceDoneMessage('create-draft', { number: '' }), 'Der Entwurf wurde gespeichert.')
+ assert.equal(invoiceDoneMessage('send', {}), 'Die Rechnung wurde gesendet.')
+ assert.equal(invoiceDoneMessage('xyz', r), '')
+})
+
+test('invoiceFailureMessage: 502 mit invoiceId nennt die Nummer, sonst die Meldung des Servers', () => {
+ const e502 = Object.assign(new Error('Stripe ist gerade nicht erreichbar.'), { status: 502, invoiceId: 'in_9', invoiceNumber: 'WK-0009' })
+ assert.equal(isFinalizedNotSent(e502), true)
+ assert.equal(invoiceFailureMessage(e502), 'Rechnung WK-0009 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.')
+ const noNumber = Object.assign(new Error('x'), { status: 502, invoiceId: 'in_9' })
+ assert.equal(invoiceFailureMessage(noNumber), 'Die Rechnung wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.')
+ const plain502 = Object.assign(new Error('Stripe ist gerade nicht erreichbar.'), { status: 502 })
+ assert.equal(isFinalizedNotSent(plain502), false)
+ assert.equal(invoiceFailureMessage(plain502), 'Stripe ist gerade nicht erreichbar.')
+ assert.equal(invoiceFailureMessage(Object.assign(new Error('Diese Rechnung wird gerade schon erstellt.'), { status: 409 })), 'Diese Rechnung wird gerade schon erstellt.')
+ assert.equal(invoiceFailureMessage(null), 'Die Aktion ist fehlgeschlagen.')
+ assert.equal(isFinalizedNotSent(null), false)
+})
+
+test('invoiceFailureMessage: scheiterte nur das Nachlesen, gilt die Meldung des Servers (die Rechnung ist raus)', () => {
+ const sentButUnreadable = Object.assign(
+ new Error('Rechnung WK-0010 wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.'),
+ { status: 502, invoiceId: 'in_10', invoiceNumber: 'WK-0010' }
+ )
+ assert.equal(invoiceFailureMessage(sentButUnreadable), 'Rechnung WK-0010 wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.')
+ const draftUnreadable = Object.assign(new Error('Der Entwurf wurde angelegt, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.'), { status: 502, invoiceId: 'in_11', invoiceNumber: '' })
+ assert.match(invoiceFailureMessage(draftUnreadable), /^Der Entwurf wurde angelegt/)
+ const notSent = Object.assign(new Error('Rechnung WK-0010 wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.'), { status: 502, invoiceId: 'in_10', invoiceNumber: 'WK-0010' })
+ assert.equal(invoiceFailureMessage(notSent), 'Rechnung WK-0010 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.')
+})
+
+test('finalizedFilter: mit Nummer Offen, ohne (Entwurf) Entwürfe', () => {
+ assert.equal(finalizedFilter({ invoiceNumber: 'WK-0010' }), 'offen')
+ assert.equal(finalizedFilter({ invoiceNumber: '' }), 'entwurf')
+ assert.equal(finalizedFilter({}), 'entwurf')
+ assert.equal(finalizedFilter(null), 'entwurf')
+})
+
+// --- Rechnung schreiben (Dialog) ---------------------------------------------------------------------------
+
+const CUST = { $id: 'c1', name: 'Kunde Eins', email: 'eins@example.invalid', customerStatus: 'customer' }
+const FORM = (over = {}) => ({
+ customer: CUST,
+ items: [{ description: 'Webdesign', quantity: '2', price: '1.234,50' }],
+ days: '14',
+ memo: '',
+ ...over,
+})
+
+test('parseQuantity: ganze Zahlen 1 bis 999', () => {
+ assert.equal(parseQuantity('1'), 1)
+ assert.equal(parseQuantity(' 12 '), 12)
+ assert.equal(parseQuantity('999'), 999)
+ assert.equal(parseQuantity('007'), 7)
+ assert.equal(parseQuantity(3), 3)
+ for (const bad of ['0', '1000', '', '1,5', '-1', 'a', null, undefined, '1.0']) assert.equal(parseQuantity(bad), null, String(bad))
+})
+
+test('parseUnitPrice: Betrag in Euro, 0,01 € bis 100.000,00 €', () => {
+ assert.equal(parseUnitPrice('1.234,50'), 123450)
+ assert.equal(parseUnitPrice('120'), 12000)
+ assert.equal(parseUnitPrice('0,01'), 1)
+ assert.equal(parseUnitPrice('100.000,00'), INVOICE_LIMITS.maxUnitCents)
+ for (const bad of ['0', '0,00', '100.000,01', '-5', 'abc', '', '12.50', null, undefined]) assert.equal(parseUnitPrice(bad), null, String(bad))
+})
+
+test('lineTotalCents und invoiceTotalCents: ungültige Positionen zählen 0', () => {
+ assert.equal(lineTotalCents({ quantity: '2', price: '1.234,50' }), 246900)
+ assert.equal(lineTotalCents({ quantity: '0', price: '5' }), null)
+ assert.equal(lineTotalCents({ quantity: '1', price: '' }), null)
+ assert.equal(lineTotalCents(null), null)
+ assert.equal(invoiceTotalCents([{ quantity: '2', price: '10' }, { quantity: 'x', price: '10' }, { quantity: '1', price: '0,50' }]), 2050)
+ assert.equal(invoiceTotalCents(undefined), 0)
+})
+
+test('validateInvoiceForm: gültiges Formular -> Anfrage in Cent (ohne leere Notiz)', () => {
+ const r = validateInvoiceForm(FORM())
+ assert.equal(r.ok, true)
+ assert.deepEqual(r.payload, {
+ customerId: 'c1',
+ items: [{ description: 'Webdesign', quantity: 2, unitAmountCents: 123450 }],
+ daysUntilDue: 14,
+ })
+ assert.equal(r.totalCents, 246900)
+ const withMemo = validateInvoiceForm(FORM({ memo: ' Danke! ', days: '0', items: [{ description: ' A ', quantity: '1', price: '0,01' }] }))
+ assert.equal(withMemo.ok, true)
+ assert.equal(withMemo.payload.memo, 'Danke!')
+ assert.equal(withMemo.payload.daysUntilDue, 0)
+ assert.equal(withMemo.payload.items[0].description, 'A')
+})
+
+test('validateInvoiceForm: Kunde fehlt, Lead, ohne E-Mail', () => {
+ assert.deepEqual(validateInvoiceForm(FORM({ customer: null })), { ok: false, error: 'Bitte einen Kunden wählen.' })
+ const lead = validateInvoiceForm(FORM({ customer: { ...CUST, customerStatus: 'lead' } }))
+ assert.equal(lead.ok, false)
+ assert.equal(lead.error, 'Für Kunde Eins kann keine Rechnung geschrieben werden: Noch kein fester Kunde.')
+ const noMail = validateInvoiceForm(FORM({ customer: { ...CUST, email: '' } }))
+ assert.equal(noMail.error, 'Für Kunde Eins kann keine Rechnung geschrieben werden: E-Mail fehlt.')
+})
+
+test('validateInvoiceForm: Positionen werden mit Nummer gemeldet', () => {
+ const good = { description: 'A', quantity: '1', price: '5' }
+ assert.equal(validateInvoiceForm(FORM({ items: [] })).error, 'Eine Rechnung braucht 1 bis 20 Positionen.')
+ assert.equal(validateInvoiceForm(FORM({ items: Array(21).fill(good) })).error, 'Eine Rechnung braucht 1 bis 20 Positionen.')
+ assert.equal(validateInvoiceForm(FORM({ items: [good, { ...good, description: ' ' }] })).error, 'Position 2: Bitte eine Beschreibung eingeben.')
+ assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, description: 'x'.repeat(201) }] })).error, 'Position 1: Die Beschreibung darf höchstens 200 Zeichen lang sein.')
+ assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, quantity: '0' }] })).error, 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
+ assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, price: '0' }] })).error, 'Position 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).')
+ assert.equal(validateInvoiceForm(FORM({ items: [{ ...good, price: 'abc' }] })).error, 'Position 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).')
+ assert.equal(validateInvoiceForm(FORM({ items: Array(20).fill(good) })).ok, true)
+})
+
+test('validateInvoiceForm: Summe, Zahlungsziel und Notiz', () => {
+ const big = { description: 'A', quantity: '999', price: '100.000,00' }
+ assert.equal(validateInvoiceForm(FORM({ items: [big] })).error, 'Die Rechnungssumme ist zu hoch (höchstens 999.999,99 €).')
+ for (const days of ['', '-1', '61', '1,5', 'x']) {
+ assert.equal(validateInvoiceForm(FORM({ days })).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', days)
+ }
+ assert.equal(validateInvoiceForm(FORM({ days: '60' })).ok, true)
+ assert.equal(validateInvoiceForm(FORM({ memo: 'x'.repeat(1001) })).error, 'Die Notiz darf höchstens 1000 Zeichen lang sein.')
+ assert.equal(validateInvoiceForm(FORM({ memo: 'x'.repeat(1000) })).ok, true)
+ assert.equal(validateInvoiceForm(undefined).ok, false)
+})
+
+const CUSTOMERS = [
+ { $id: 'c3', name: 'Zeta GmbH', email: 'zeta@example.invalid', customerStatus: 'customer' },
+ { $id: 'c1', name: 'Alpha AG', email: 'alpha@example.invalid' }, // ohne Status = Kunde
+ { $id: 'c2', name: 'Beta Lead', email: 'beta@example.invalid', customerStatus: 'lead' },
+ { $id: 'c4', name: 'Gamma', email: '', customerStatus: 'customer' },
+ { $id: 'c5', companyName: 'Delta Firma', email: 'delta@example.invalid', customerStatus: 'customer' },
+ { $id: 'c6', name: 'Alpha AG', email: 'alpha2@example.invalid', customerStatus: 'customer' },
+ { $id: 'c7', name: '', email: 'x@example.invalid' },
+ null,
+]
+
+test('customerName: name, sonst companyName', () => {
+ assert.equal(customerName({ name: ' A ', companyName: 'B' }), 'A')
+ assert.equal(customerName({ companyName: 'B' }), 'B')
+ assert.equal(customerName({}), '')
+ assert.equal(customerName(null), '')
+})
+
+test('customerPickerOptions: nach Namen sortiert, Grund bei gesperrten, gleiche Namen mit E-Mail unterscheidbar', () => {
+ const o = customerPickerOptions(CUSTOMERS)
+ assert.deepEqual(o.map((x) => x.label), [
+ 'Alpha AG (alpha@example.invalid)',
+ 'Alpha AG (alpha2@example.invalid)',
+ 'Beta Lead',
+ 'Delta Firma',
+ 'Gamma',
+ 'Zeta GmbH',
+ ])
+ const byId = Object.fromEntries(o.map((x) => [x.id, x]))
+ assert.equal(byId.c1.ok, true)
+ assert.equal(byId.c2.ok, false)
+ assert.equal(byId.c2.reason, 'Noch kein fester Kunde')
+ assert.equal(byId.c4.ok, false)
+ assert.equal(byId.c4.reason, 'E-Mail fehlt')
+ assert.equal(byId.c5.name, 'Delta Firma')
+ assert.equal(new Set(o.map((x) => x.label.toLowerCase())).size, o.length)
+ assert.deepEqual(customerPickerOptions(undefined), [])
+})
+
+test('customerSuggestions: nur Kunden, für die eine Rechnung möglich ist; Namensanfang zuerst; Teiltreffer auch per E-Mail', () => {
+ const o = customerPickerOptions(CUSTOMERS)
+ const all = customerSuggestions(o, '')
+ assert.deepEqual(all.map((s) => s.value), ['Alpha AG (alpha@example.invalid)', 'Alpha AG (alpha2@example.invalid)', 'Delta Firma', 'Zeta GmbH'])
+ assert.equal(all[2].hint, 'delta@example.invalid')
+ assert.ok(!all.some((s) => s.value.includes('Beta') || s.value === 'Gamma'), 'Lead und Kunde ohne E-Mail fehlen')
+ assert.deepEqual(customerSuggestions(o, 'ta').map((s) => s.value), ['Delta Firma', 'Zeta GmbH'])
+ assert.deepEqual(customerSuggestions(o, 'zeta@').map((s) => s.value), ['Zeta GmbH'])
+ assert.deepEqual(customerSuggestions(o, 'gamma'), [])
+ assert.equal(customerSuggestions(o, '', 2).length, 2)
+})
+
+test('resolveCustomerText: ok, gesperrt mit Grund, unbekannt, leer', () => {
+ const o = customerPickerOptions(CUSTOMERS)
+ assert.equal(resolveCustomerText(o, '').state, 'empty')
+ assert.equal(resolveCustomerText(o, ' ').state, 'empty')
+ const ok = resolveCustomerText(o, 'zeta gmbh')
+ assert.equal(ok.state, 'ok')
+ assert.equal(ok.option.id, 'c3')
+ const blocked = resolveCustomerText(o, 'Beta Lead')
+ assert.equal(blocked.state, 'blocked')
+ assert.equal(blocked.option.reason, 'Noch kein fester Kunde')
+ assert.equal(resolveCustomerText(o, 'Gamma').option.reason, 'E-Mail fehlt')
+ assert.equal(resolveCustomerText(o, 'Zeta').state, 'unknown', 'nur der ganze Name zählt, ein Teil nicht')
+ assert.equal(resolveCustomerText(o, 'Nie gehört').state, 'unknown')
+ assert.equal(resolveCustomerText(o, 'Alpha AG').state, 'unknown', 'mehrdeutiger Name braucht die E-Mail im Feld')
+})
+
+// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
+
+const TX = (over = {}) => ({
+ $id: 'b1', status: 'BOOK', bookingDate: '2026-01-05', valueDate: '2026-01-05', amountCents: 16879, currency: 'EUR',
+ counterpartyName: 'KUNDE EINS GMBH', counterpartyIban: 'DE00', remittance: 'Rechnung WK-0005 vom 28.07.2026', mcc: '',
+ category: '', matchedInvoiceId: '', matchedInvoiceNumber: '', matchedPayoutId: '', fixedCostId: '', note: '',
+ vorschlaege: [], ...over,
+})
+
+test('sortTransactions: neueste zuerst, gleiche Tage behalten die Serverreihenfolge, Vormerkung nach Wertstellung', () => {
+ const list = [
+ TX({ $id: 'a', bookingDate: '2026-01-05' }),
+ TX({ $id: 'b', bookingDate: '2026-03-01' }),
+ TX({ $id: 'c', bookingDate: '2026-01-05' }),
+ TX({ $id: 'd', bookingDate: '', valueDate: '2026-02-10', status: 'PDNG' }),
+ null,
+ ]
+ assert.deepEqual(sortTransactions(list).map((t) => t.$id), ['b', 'd', 'a', 'c'])
+ assert.deepEqual(sortTransactions(undefined), [])
+ const before = list.map((t) => t && t.$id)
+ sortTransactions(list)
+ assert.deepEqual(list.map((t) => t && t.$id), before, 'Eingabe bleibt unverändert')
+})
+
+test('strongestSuggestion und suggestionText', () => {
+ const low = { kind: 'invoice', id: 'in_l', number: 'WK-0001', confidence: 'niedrig', reason: '', amountDiffers: false }
+ const mid = { kind: 'invoice', id: 'in_m', number: 'WK-0002', confidence: 'mittel', reason: '', amountDiffers: true }
+ const hi = { kind: 'payout', id: 'po_h', number: '', confidence: 'hoch', reason: '', amountDiffers: false }
+ const hi2 = { kind: 'invoice', id: 'in_h2', number: 'WK-0003', confidence: 'hoch', reason: '', amountDiffers: false }
+ assert.equal(strongestSuggestion([low, mid, hi, hi2]), hi)
+ assert.equal(strongestSuggestion([low, mid]), mid)
+ assert.equal(strongestSuggestion([]), null)
+ assert.equal(strongestSuggestion(undefined), null)
+ assert.equal(suggestionText(hi2), 'Vorschlag: Rechnung WK-0003 (hoch)')
+ assert.equal(suggestionText(hi), 'Vorschlag: Stripe-Auszahlung (hoch)')
+ assert.equal(suggestionText(mid), 'Vorschlag: Rechnung WK-0002 (mittel) · Betrag weicht ab')
+ assert.equal(suggestionText({ kind: 'invoice', id: 'in_x', number: '' }), 'Vorschlag: Rechnung in_x')
+ assert.equal(suggestionText(null), '')
+})
+
+test('assignmentText: Rechnung mit Nummer, Kategorien, Offen', () => {
+ assert.equal(assignmentText(TX({ category: 'rechnung', matchedInvoiceNumber: 'WK-0005' })), 'Rechnung WK-0005')
+ assert.equal(assignmentText(TX({ category: 'rechnung', matchedInvoiceNumber: '', matchedInvoiceId: 'in_1' })), 'Rechnung in_1')
+ assert.equal(assignmentText(TX({ category: 'stripe-auszahlung' })), 'Stripe-Auszahlung')
+ assert.equal(assignmentText(TX({ category: 'fixkosten' })), 'Fixkosten')
+ assert.equal(assignmentText(TX({ category: 'rechnung-extern' })), 'Einnahme ohne Stripe-Rechnung')
+ assert.equal(assignmentText(TX({ category: 'privat' })), 'Privat/Umbuchung')
+ assert.equal(assignmentText(TX({ category: 'sonstiges' })), 'Sonstiges')
+ assert.equal(assignmentText(TX({ category: '' })), 'Offen')
+ assert.equal(assignmentText(TX({ category: 'neu' })), 'neu')
+})
+
+test('transactionRows: Texte, Vorzeichen, Vorschlag, Aktionen', () => {
+ const rows = transactionRows([
+ TX({ $id: 'in', vorschlaege: [{ kind: 'invoice', id: 'in_5', number: 'WK-0005', confidence: 'hoch', reason: 'x', amountDiffers: false }] }),
+ TX({ $id: 'out', bookingDate: '2026-01-04', amountCents: -1000, counterpartyName: 'Hetzner', remittance: 'Server' }),
+ TX({ $id: 'rg', bookingDate: '2026-01-03', category: 'rechnung', matchedInvoiceNumber: 'WK-0004', note: 'bar' }),
+ TX({ $id: 'pv', bookingDate: '2026-01-02', category: 'privat' }),
+ TX({ $id: 'pd', bookingDate: '', valueDate: '2026-01-01', status: 'PDNG', counterpartyName: '', counterpartyIban: 'DE99', vorschlaege: [{ kind: 'invoice', id: 'in_5', number: 'WK-0005', confidence: 'hoch', reason: 'x', amountDiffers: false }] }),
+ ])
+ assert.deepEqual(rows.map((r) => r.id), ['in', 'out', 'rg', 'pv', 'pd'])
+ const [inn, out, rg, pv, pd] = rows
+ assert.equal(inn.dateText, '05.01.2026')
+ assert.equal(inn.counterparty, 'KUNDE EINS GMBH')
+ assert.equal(inn.remittance, 'Rechnung WK-0005 vom 28.07.2026')
+ assert.equal(inn.amountText, '+168,79 €')
+ assert.equal(inn.incoming, true)
+ assert.equal(inn.assignmentText, 'Offen')
+ assert.equal(inn.suggestionText, 'Vorschlag: Rechnung WK-0005 (hoch)')
+ assert.equal(inn.action, 'assign')
+ assert.equal(inn.actionLabel, 'Zuordnen')
+ assert.equal(out.amountText, `${MINUS}10,00 €`)
+ assert.equal(out.incoming, false)
+ assert.equal(out.action, 'assign')
+ assert.equal(out.suggestionText, '')
+ assert.equal(rg.assignmentText, 'Rechnung WK-0004')
+ assert.equal(rg.action, null, 'eine zugeordnete Rechnung lässt sich nicht lösen')
+ assert.equal(rg.actionLabel, '')
+ assert.equal(rg.note, 'bar')
+ assert.equal(pv.action, 'change')
+ assert.equal(pv.actionLabel, 'Ändern')
+ assert.equal(pd.pending, true)
+ assert.equal(pd.suggestionText, '', 'vorgemerkt: kein Rechnungsvorschlag')
+ assert.equal(pd.dateText, '01.01.2026')
+ assert.equal(pd.counterparty, 'DE99')
+ assert.equal(transactionRows(null).length, 0)
+})
+
+// --- Zuordnen (Dialog) -------------------------------------------------------------------------------------
+
+const OPEN = (over = {}) => INV({ status: 'offen', overdueDays: 0, ...over })
+const SUG_INV = (over = {}) => ({ kind: 'invoice', id: 'in_1', number: 'WK-0006', confidence: 'hoch', reason: 'Rechnungsnummer im Verwendungszweck', amountDiffers: false, ...over })
+
+test('assignModel: Eingang mit Vorschlägen, anderen offenen Rechnungen und Kategorien', () => {
+ const tx = TX({ amountCents: 5000, vorschlaege: [SUG_INV(), { kind: 'payout', id: 'po_1', number: '', confidence: 'hoch', reason: 'Stripe-Auszahlung gleicher Betrag', amountDiffers: false }] })
+ const m = assignModel(tx, {
+ invoices: [OPEN(), OPEN({ id: 'in_2', number: 'WK-0007', remainingCents: 5000 }), OPEN({ id: 'in_3', number: 'WK-0008', remainingCents: 999 }), INV({ id: 'in_4', status: 'bezahlt' }), INV({ id: 'in_5', status: 'entwurf' })],
+ fixedCosts: [{ $id: 'f1', name: 'Hetzner', amountCents: 1000, interval: 'monat', active: true }],
+ })
+ assert.equal(m.incoming, true)
+ assert.equal(m.pending, false)
+ assert.equal(m.assigned, false)
+ assert.equal(m.hint, '')
+ assert.deepEqual(m.suggestions.map((s) => s.key), ['invoice:in_1', 'payout:po_1'])
+ assert.equal(m.suggestions[0].number, 'WK-0006')
+ assert.deepEqual(m.suggestions[0].body, { invoiceId: 'in_1' })
+ assert.deepEqual(m.suggestions[1].body, { payoutId: 'po_1' })
+ assert.deepEqual(m.invoices.map((o) => o.key), ['invoice:in_2', 'invoice:in_3'], 'Vorschlag nicht doppelt, nur offene/überfällige')
+ assert.equal(m.invoices[0].disabled, false)
+ assert.equal(m.invoices[1].disabled, true)
+ assert.equal(m.invoices[1].disabledReason, 'Betrag weicht ab (offen: 9,99 €)')
+ assert.deepEqual(m.fixedCosts, [], 'keine Fixkosten für Eingänge')
+ assert.deepEqual(m.categories.map((c) => c.label), ['Einnahme ohne Stripe-Rechnung', 'Privat/Umbuchung', 'Sonstiges'])
+ assert.deepEqual(m.categories.map((c) => c.body), [{ category: 'rechnung-extern' }, { category: 'privat' }, { category: 'sonstiges' }])
+ assert.ok(m.byKey['invoice:in_2'] && m.byKey['category:privat'] && m.byKey['payout:po_1'])
+})
+
+test('assignModel: Vorschlag mit abweichendem Betrag ist nicht wählbar', () => {
+ const tx = TX({ amountCents: 5000, vorschlaege: [SUG_INV({ amountDiffers: true, confidence: 'mittel' })] })
+ const m = assignModel(tx, { invoices: [] })
+ assert.equal(m.suggestions[0].disabled, true)
+ assert.match(m.suggestions[0].disabledReason, /Betrag weicht ab/)
+ const known = assignModel(TX({ amountCents: 5000, vorschlaege: [SUG_INV()] }), { invoices: [OPEN({ remainingCents: 4000 })] })
+ assert.equal(known.suggestions[0].disabled, true, 'auch wenn die Liste den Unterschied zeigt')
+})
+
+test('assignModel: Ausgang bekommt Fixkosten (nur aktive) und Privat/Sonstiges, keine Rechnungen', () => {
+ const tx = TX({ amountCents: -1000, vorschlaege: [SUG_INV()] })
+ const m = assignModel(tx, {
+ invoices: [OPEN()],
+ fixedCosts: [
+ { $id: 'f1', name: 'Hetzner', amountCents: 1000, interval: 'monat', active: true },
+ { $id: 'f2', name: 'Alt', amountCents: 500, interval: 'jahr', active: false },
+ { $id: 'f3', name: 'Domain', amountCents: 1999, interval: 'jahr' },
+ ],
+ })
+ assert.equal(m.outgoing, true)
+ assert.deepEqual(m.suggestions, [])
+ assert.deepEqual(m.invoices, [])
+ assert.deepEqual(m.fixedCosts.map((f) => f.key), ['fixedCost:f1', 'fixedCost:f3'])
+ assert.equal(m.fixedCosts[0].label, 'Hetzner · 10,00 € monatlich')
+ assert.deepEqual(m.fixedCosts[0].body, { fixedCostId: 'f1' })
+ assert.deepEqual(m.categories.map((c) => c.body.category), ['privat', 'sonstiges'])
+})
+
+test('assignModel: Vorgemerkt (PDNG) nur Kategorien und Fixkosten, mit Hinweis', () => {
+ assert.equal(PENDING_ASSIGN_HINT, 'Vorgemerkte Buchungen können erst nach der Buchung einer Rechnung zugeordnet werden.')
+ const incoming = assignModel(TX({ status: 'PDNG', amountCents: 5000, vorschlaege: [SUG_INV()] }), { invoices: [OPEN()] })
+ assert.equal(incoming.pending, true)
+ assert.equal(incoming.hint, PENDING_ASSIGN_HINT)
+ assert.deepEqual(incoming.suggestions, [])
+ assert.deepEqual(incoming.invoices, [])
+ assert.ok(incoming.categories.length > 0)
+ assert.ok(!Object.keys(incoming.byKey).some((k) => k.startsWith('invoice:') || k.startsWith('payout:')))
+ const outgoing = assignModel(TX({ status: 'PDNG', amountCents: -300 }), { fixedCosts: [{ $id: 'f1', name: 'X', amountCents: 300, interval: 'monat' }] })
+ assert.deepEqual(outgoing.fixedCosts.map((f) => f.key), ['fixedCost:f1'])
+})
+
+test('assignModel: zugeordnete Buchung zeigt nur die Zuordnung; Rechnung nicht lösbar', () => {
+ const privat = assignModel(TX({ category: 'privat' }), { invoices: [OPEN()] })
+ assert.equal(privat.assigned, true)
+ assert.equal(privat.currentText, 'Privat/Umbuchung')
+ assert.equal(privat.canUnassign, true)
+ assert.deepEqual(privat.byKey, {})
+ assert.equal(privat.categories.length, 0)
+ const rg = assignModel(TX({ category: 'rechnung', matchedInvoiceNumber: 'WK-0005' }))
+ assert.equal(rg.currentText, 'Rechnung WK-0005')
+ assert.equal(rg.canUnassign, false)
+ const none = assignModel(null)
+ assert.equal(none.assigned, false)
+})
+
+test('assignBody und invoiceAssignConfirmText', () => {
+ assert.deepEqual(assignBody({ body: { invoiceId: 'in_1' } }, ''), { invoiceId: 'in_1' })
+ assert.deepEqual(assignBody({ body: { category: 'privat' } }, ' Miete privat '), { category: 'privat', note: 'Miete privat' })
+ assert.deepEqual(assignBody(null, 'x'), { note: 'x' })
+ assert.equal(invoiceAssignConfirmText('WK-0005'), 'Die Rechnung WK-0005 wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.')
+})