Finanzen: Rechnungen schreiben und verwalten, Buchungen zuordnen

Block 6 (Rechnungen: Filter, Altersgruppen, Aktionen, Dialog "Rechnung schreiben")
und Block 7 (Buchungen mit Paging, Zuordnen-Dialog, bei Rechnungen zweistufig).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:12:18 +00:00
1 parent 18bc3012e0
commit 1feeb18c08
12 files changed
+2436 -8

No files matched your search

+316
View File
@@ -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) => (
<label key={o.key} className={`fin-choice${o.disabled ? ' fin-choice-off' : ''}`}>
<input
type="radio"
name="fin-assign-target"
value={o.key}
checked={selected === o.key}
disabled={o.disabled || disabled}
onChange={() => choose(o.key)}
/>
<span className="fin-choice-text">
<span className="fin-choice-label">{o.label}</span>
{(o.confidence || o.detail) && (
<span className="fin-choice-detail muted">
{o.confidence ? `Übereinstimmung: ${o.confidence}` : ''}
{o.confidence && o.detail ? ' · ' : ''}
{o.detail}
</span>
)}
{o.disabled && o.disabledReason && <span className="fin-choice-detail fin-hint-warn">{o.disabledReason}</span>}
</span>
</label>
)
return (
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
<button type="button" className="overlay-close fin-close" onClick={close} disabled={disabled} aria-label="Schließen">
<FaXmark aria-hidden="true" />
</button>
<div
className="overlay-content fin-dialog fin-assign"
role="dialog"
aria-modal="true"
aria-labelledby="fin-assign-title"
tabIndex={-1}
ref={dialogRef}
>
<h2 id="fin-assign-title" className="mb-2">Buchung zuordnen</h2>
<dl className="fin-tx-facts">
<div><dt>Datum</dt><dd>{row?.dateText}{row?.pending && <> <Badge tone="muted">vorgemerkt</Badge></>}</dd></div>
<div><dt>Gegenpartei</dt><dd>{row?.counterparty}</dd></div>
{row?.remittance && <div><dt>Verwendungszweck</dt><dd className="fin-wrap">{row.remittance}</dd></div>}
<div><dt>Betrag</dt><dd className="fin-amount fin-tx-facts-amount">{row?.amountText}</dd></div>
</dl>
{error && (
<div className="fin-dialog-error" role="alert">
{error}
</div>
)}
{model.assigned ? (
<>
<p className="fin-assigned">
Zugeordnet: <strong>{model.currentText}</strong>
</p>
{!model.canUnassign && (
<p className="fin-dialog-sub">Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.</p>
)}
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Schließen</Button>
{model.canUnassign && (
<Button type="button" onClick={doUnassign} disabled={disabled}>
{busy === 'unassign' ? 'Löse …' : 'Zuordnung lösen'}
</Button>
)}
</div>
</>
) : (
<>
{model.hint && <p className="fin-dialog-sub" role="note">{model.hint}</p>}
{showInvoiceState && invoicesLoading && <p className="fin-dialog-sub" role="status">Offene Rechnungen werden geladen …</p>}
{showInvoiceState && invoicesError && (
<p className="fin-dialog-warn" role="alert">Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}</p>
)}
{outgoing && fixed.loading && <p className="fin-dialog-sub" role="status">Fixkosten werden geladen …</p>}
{outgoing && fixed.error && <p className="fin-dialog-warn" role="alert">Die Fixkosten konnten nicht geladen werden: {fixed.error}</p>}
{model.suggestions.length > 0 && (
<fieldset className="fin-choices">
<legend className="form-label">Vorschläge</legend>
{model.suggestions.map(radio)}
</fieldset>
)}
{model.invoices.length > 0 && (
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-invoice">Andere offene Rechnung</label>
<select
id="fin-assign-invoice"
className="form-control"
value={invoiceSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Rechnung wählen …</option>
{model.invoices.map((o) => (
<option key={o.key} value={o.key} disabled={o.disabled}>
{o.label}{o.disabled ? ` (${o.disabledReason})` : ''}
</option>
))}
</select>
</div>
)}
{model.fixedCosts.length > 0 && (
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-fixed">Fixkosten</label>
<select
id="fin-assign-fixed"
className="form-control"
value={fixedSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Fixkosten wählen …</option>
{model.fixedCosts.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</div>
)}
<fieldset className="fin-choices">
<legend className="form-label">Kategorie</legend>
{model.categories.map(radio)}
</fieldset>
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-note">Notiz (optional)</label>
<textarea
id="fin-assign-note"
className="form-control"
rows={2}
maxLength={NOTE_MAX}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
{confirming && option ? (
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
<p>{invoiceAssignConfirmText(option.number || option.label)}</p>
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={() => setConfirming(false)} disabled={disabled}>Zurück</Button>
<Button type="button" variant="primary" onClick={doAssign} disabled={disabled}>
{busy === 'assign' ? 'Markiere …' : 'Ja, als bezahlt markieren'}
</Button>
</div>
</div>
) : (
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Abbrechen</Button>
<Button type="button" variant="primary" onClick={primary} disabled={disabled || !option || option.disabled}>
{busy === 'assign' ? 'Ordne zu …' : 'Zuordnen'}
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
@@ -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 ? (
<InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={fixedCustomer} source={null} />
) : (
<PickerForm onClose={onClose} onCreated={onCreated} />
)
}
function PickerForm({ onClose, onCreated }) {
const { customers, loading, error } = useCustomers()
return <InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={null} source={{ customers, loading, error }} />
}
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 (
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
<button type="button" className="overlay-close fin-close" onClick={onClose} disabled={disabled} aria-label="Schließen">
<FaXmark aria-hidden="true" />
</button>
<div
className="overlay-content fin-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="fin-invoice-title"
tabIndex={-1}
ref={dialogRef}
>
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
{error && (
<div className="fin-dialog-error" role="alert">
{error}
</div>
)}
{/* Enter sendet nie (das wäre eine Mail an den Kunden): nur die beiden Knöpfe unten lösen aus */}
<form onSubmit={(e) => e.preventDefault()}>
<div className="form-group">
<label className="form-label" htmlFor="fin-invoice-customer">Kunde</label>
{fixedCustomer ? (
<p className="fin-fixed-customer">
<strong>{customerName(fixedCustomer) || 'Kunde'}</strong>
{fixedCustomer.email ? <span className="fin-dialog-sub"> · {fixedCustomer.email}</span> : null}
</p>
) : source?.loading ? (
<p className="fin-dialog-sub" role="status">Kunden werden geladen …</p>
) : source?.error ? (
<p className="fin-dialog-warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
) : (
<AutocompleteInput
id="fin-invoice-customer"
value={customerText}
onChange={(v) => {
setCustomerText(v)
if (error) setError('')
}}
getSuggestions={(t) => customerSuggestions(options, t)}
placeholder="Kunde suchen …"
/>
)}
{!customerBlocked.ok && (
<p className="fin-dialog-warn" role="alert">
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
</p>
)}
{!fixedCustomer && resolved.state === 'unknown' && (
<p className="fin-dialog-sub">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
)}
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
<p className="fin-dialog-sub">Die Rechnung geht an {customer.email}.</p>
)}
</div>
<fieldset className="fin-items">
<legend className="form-label">Positionen</legend>
{items.map((item, index) => {
const line = lineTotalCents(item)
const n = index + 1
return (
<div key={item.uid} className="fin-item">
<div className="fin-item-field fin-item-desc">
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
<input
id={`fin-item-desc-${item.uid}`}
className="form-control"
maxLength={INVOICE_LIMITS.maxDescription}
value={item.description}
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
aria-label={`Position ${n}: Beschreibung`}
placeholder="z. B. Webdesign Startseite"
/>
</div>
<div className="fin-item-field fin-item-qty">
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
<input
id={`fin-item-qty-${item.uid}`}
className="form-control fin-amount"
inputMode="numeric"
value={item.quantity}
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
aria-label={`Position ${n}: Menge`}
/>
</div>
<div className="fin-item-field fin-item-price">
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
<input
id={`fin-item-price-${item.uid}`}
className="form-control fin-amount"
inputMode="decimal"
value={item.price}
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
aria-label={`Position ${n}: Einzelpreis in Euro`}
placeholder="0,00"
/>
</div>
<div className="fin-item-field fin-item-sum">
<span className="fin-item-label">Summe</span>
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
</div>
<div className="fin-item-field fin-item-remove">
<button
type="button"
className="fin-icon-btn"
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
disabled={items.length <= 1}
aria-label={`Position ${n} entfernen`}
title="Position entfernen"
>
<FaTrashCan aria-hidden="true" />
</button>
</div>
</div>
)
})}
<div className="fin-items-foot">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
disabled={items.length >= INVOICE_LIMITS.maxItems}
>
<FaPlus aria-hidden="true" /> Position
</Button>
<div className="fin-total" aria-live="polite">
<span>Summe</span>
<span className="fin-amount fin-total-value">{euro(total)}</span>
</div>
</div>
<p className="fin-dialog-sub">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
</fieldset>
<div className="fin-dialog-row">
<div className="form-group fin-days">
<label className="form-label" htmlFor="fin-invoice-days">Zahlungsziel in Tagen</label>
<input
id="fin-invoice-days"
className="form-control fin-amount"
inputMode="numeric"
value={days}
onChange={(e) => {
setDays(e.target.value)
if (error) setError('')
}}
/>
</div>
<div className="form-group fin-memo">
<label className="form-label" htmlFor="fin-invoice-memo">Notiz (erscheint auf der Rechnung)</label>
<textarea
id="fin-invoice-memo"
className="form-control"
rows={2}
maxLength={INVOICE_LIMITS.maxMemo}
value={memo}
onChange={(e) => setMemo(e.target.value)}
/>
</div>
</div>
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={onClose} disabled={disabled}>Abbrechen</Button>
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
</Button>
<Button type="button" variant="primary" onClick={() => submit(true)} disabled={disabled}>
{busy === 'send' ? 'Sende …' : 'Senden'}
</Button>
</div>
</form>
</div>
</div>
)
}
+277
View File
@@ -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 (
<section id={filterCustomer ? undefined : INVOICES_ANCHOR} className="fin-block">
<Card
title="Rechnungen"
className="fin-invoices-card"
bodyStyle={{ padding: 0 }}
actions={
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
<FaPlus aria-hidden="true" /> Rechnung schreiben
</Button>
}
>
{ages.length > 0 && (
<ul className="fin-ages" aria-label="Offene Rechnungen nach Alter">
{ages.map((cell) => (
<li key={cell.key} className="fin-age">
<span className="fin-age-label">{cell.label}</span>
<span className="fin-age-count">{cell.count} {cell.count === 1 ? 'Rechnung' : 'Rechnungen'}</span>
<span className="fin-amount fin-age-sum">{cell.sumText}</span>
</li>
))}
</ul>
)}
<div className="fin-toolbar">
<div className="seg" role="group" aria-label="Rechnungen filtern">
{INVOICE_FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={status === f.key ? 'active' : ''}
aria-pressed={status === f.key}
onClick={() => pickFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
{message && (
<p className={`fin-hint fin-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
{message.text}
</p>
)}
{loading ? (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Rechnungen werden geladen …</p>
</div>
) : loadError ? (
<div className="fin-load-error">
<p className="fin-hint fin-hint-warn" role="alert">{loadError}</p>
<Button size="sm" onClick={reload}>Erneut versuchen</Button>
</div>
) : rows.length === 0 ? (
<EmptyState title="Keine Rechnungen in dieser Ansicht." />
) : (
<div className="fin-table-wrap">
<table className="ui-table fin-invoices" role="table">
<caption className="fin-sr">Rechnungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Nr.</th>
<th role="columnheader" scope="col">Kunde</th>
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Fällig</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
<th role="columnheader" scope="col" className="fin-amount">Offen</th>
<th role="columnheader" scope="col">Status</th>
<th role="columnheader" scope="col">Aktionen</th>
</tr>
</thead>
<tbody role="rowgroup">
{paged.items.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-inv-number">
{row.numberText}
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
</td>
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
<td role="cell" className="fin-amount fin-inv-remaining" data-label="Offen">{row.remainingText}</td>
<td role="cell" className="fin-inv-status">
<div className="fin-inv-statusbox">
<Badge tone={row.statusTone}>{row.statusLabel}</Badge>
{row.statusNote && <span className="fin-inv-note muted">{row.statusNote}</span>}
</div>
</td>
<td role="cell" className="fin-inv-actions">
<div className="fin-actions">
{row.actions.map((action) =>
action.url ? (
<Button
key={action.key}
as="a"
size="sm"
variant="ghost"
href={action.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${action.label} öffnen: ${row.numberText}, ${row.customerText}`}
>
{action.label}
</Button>
) : (
<Button
key={action.key}
size="sm"
variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'}
onClick={() => run(row, action)}
disabled={busy !== null}
aria-label={`${action.label}: ${row.numberText}, ${row.customerText}`}
>
{busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label}
</Button>
)
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Rechnungen" onPage={setPage} />
</Card>
<InvoiceCreateModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={handleCreated}
fixedCustomer={customer}
/>
</section>
)
}
+23
View File
@@ -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 (
<nav className="fin-pager" aria-label={`Seiten der ${noun}`}>
<Button size="sm" variant="ghost" onClick={() => onPage(page - 1)} disabled={page <= 1}>
Zurück
</Button>
<span className="fin-pager-info" aria-live="polite">
Seite {page} von {pages}
{typeof total === 'number' ? ` · ${total} ${noun}` : ''}
</span>
<Button size="sm" variant="ghost" onClick={() => onPage(page + 1)} disabled={page >= pages}>
Weiter
</Button>
</nav>
)
}
+113
View File
@@ -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 = (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Buchungen werden geladen …</p>
</div>
)
} else if (!loaded) {
body = (
<div className="fin-load-error">
<p className="fin-hint fin-hint-warn" role="alert">{error || 'Die Buchungen konnten nicht geladen werden.'}</p>
<Button size="sm" onClick={onReload}>Erneut versuchen</Button>
</div>
)
} else if (rows.length === 0) {
body = <EmptyState title="Noch keine Buchungen gespeichert." hint="Sobald die Bank verbunden und abgerufen ist, erscheinen sie hier." />
} else {
body = (
<div className="fin-table-wrap">
<table className="ui-table fin-tx" role="table">
<caption className="fin-sr">Buchungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Gegenpartei und Verwendungszweck</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
<th role="columnheader" scope="col">Zuordnung</th>
<th role="columnheader" scope="col"><span className="fin-sr">Aktion</span></th>
</tr>
</thead>
<tbody role="rowgroup">
{paged.items.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-tx-date">{row.dateText}</td>
<td role="cell" className="fin-tx-what">
<div className="fin-tx-counterparty">{row.counterparty}</div>
{row.remittance && (
<div className="fin-tx-remittance muted" title={row.remittance}>{row.remittance}</div>
)}
</td>
<td role="cell" className={`fin-amount fin-tx-amount${row.incoming ? '' : ' fin-tx-out'}`}>{row.amountText}</td>
<td role="cell" className="fin-tx-assign">
<div className="fin-tx-badges">
<Badge tone={row.assigned ? 'ok' : 'warn'}>{row.assignmentText}</Badge>
{row.pending && <Badge tone="muted">vorgemerkt</Badge>}
</div>
{row.suggestionText && <div className="fin-tx-suggestion muted">{row.suggestionText}</div>}
{row.note && <div className="fin-tx-note muted">Notiz: {row.note}</div>}
</td>
<td role="cell" className="fin-tx-action">
{row.action && (
<Button
size="sm"
variant={row.action === 'assign' ? 'default' : 'ghost'}
onClick={() => onAssign(row.tx)}
aria-label={`${row.actionLabel}: ${row.amountText} ${row.counterparty}`}
>
{row.actionLabel}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
return (
<section id={TRANSACTIONS_ANCHOR} className="fin-block">
<Card title="Buchungen" className="fin-tx-card" bodyStyle={{ padding: 0 }}>
{loaded && error && (
<p className="fin-hint fin-hint-warn fin-notice" role="alert">
Die Buchungen konnten nicht neu geladen werden: {error}{' '}
<Button size="sm" variant="ghost" onClick={onReload} disabled={loading}>Erneut versuchen</Button>
</p>
)}
{stripeFehler && (
<p className="fin-hint fin-hint-warn fin-notice" role="status">
<FaTriangleExclamation aria-hidden="true" /> {stripeFehler}
</p>
)}
{body}
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Buchungen" onPage={setPage} />
</Card>
</section>
)
}
+39
View File
@@ -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])
}
+49
View File
@@ -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 }
}
+36
View File
@@ -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
}
+530 -2
View File
@@ -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
}
+87 -6
View File
@@ -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() {
<div className="fin-cols fin-cols-half">
<AboCard abos={data.abos} />
</div>
<InvoiceTable altersgruppen={data.rechnungen?.altersgruppen} onChanged={reloadAll} />
<TransactionTable
transactions={txs.transactions}
stripeFehler={txs.stripeFehler}
loaded={txs.loaded}
loading={txs.loading}
error={txs.error}
onReload={reloadTransactions}
onAssign={(tx) => openAssign(tx.$id)}
/>
</div>
)
}
@@ -122,7 +186,24 @@ export default function FinancePage() {
return (
<div className="page">
<PageHeader title="Finanzen" subtitle="Kontostand, Aufgaben und Zahlungen im Blick" />
{assignNotice && (
<p className="fin-hint fin-hint-warn fin-page-notice" role="alert">
{assignNotice}{' '}
<Button size="sm" variant="ghost" onClick={() => setAssignNotice('')}>Schließen</Button>
</p>
)}
{body}
{assignTx && (
<AssignDialog
key={assignTx.$id}
transaction={assignTx}
invoices={openInvoices.invoices}
invoicesLoading={openInvoices.loading}
invoicesError={openInvoices.error}
onClose={handleAssignClose}
onDone={handleAssignDone}
/>
)}
</div>
)
}
+149
View File
@@ -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; }
}
+517
View File
@@ -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.')
})