diff --git a/src/components/finance/AboCard.jsx b/src/components/finance/AboCard.jsx
new file mode 100644
index 0000000..fbd4c84
--- /dev/null
+++ b/src/components/finance/AboCard.jsx
@@ -0,0 +1,61 @@
+import { FaTriangleExclamation } from 'react-icons/fa6'
+import { Badge, Card, EmptyState } from '../ui'
+import { aboView } from '../../lib/financeView'
+
+/**
+ * Block 5: „Abos“. Einnahmen pro Monat (aktiv und Zahlung fehlgeschlagen), zahlende Kunden, gefährdete Einnahmen
+ * (nur wenn > 0) und die Liste Kunde · Produkt · Status · Betrag · nächste Abbuchung. Konnten die Abos nicht
+ * geladen werden, steht stattdessen die Meldung des Servers da (keine Zahlen).
+ */
+export default function AboCard({ abos }) {
+ const v = aboView(abos)
+
+ if (v.error) {
+ return (
+
+
+ {v.error}
+
+
+ )
+ }
+
+ return (
+
+
+ {v.monthlyText}
+ Abo-Einnahmen pro Monat
+ {v.payingText}
+ zahlende Kunden
+ {v.atRiskText && (
+ <>
+ {v.atRiskText}
+
+ gefährdet
+
+ >
+ )}
+
+
+ {v.rows.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/components/finance/ForecastTable.jsx b/src/components/finance/ForecastTable.jsx
new file mode 100644
index 0000000..4effc44
--- /dev/null
+++ b/src/components/finance/ForecastTable.jsx
@@ -0,0 +1,84 @@
+import { FaTriangleExclamation } from 'react-icons/fa6'
+import { Badge, Card, EmptyState } from '../ui'
+import { forecastNotes, forecastRows } from '../../lib/financeView'
+
+/**
+ * Block 3: „Vorschau 30 Tage“. Je Posten Datum, Bezeichnung, „sicher“/„erwartet“ (als Text), Betrag mit Vorzeichen
+ * und der laufende Saldo beider Linien ab dem Kontostand. Ohne Kontostand entfallen die Saldo-Spalten (mit Hinweis).
+ * Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Karte unter ca. 560 px)
+ * wird jede Zeile zweizeilig: Datum, Posten, Betrag / Sicherheit, Salden.
+ */
+export default function ForecastTable({ vorschau, kontostandCents, stripeDown = false, abosFehler = false }) {
+ const v = vorschau && typeof vorschau === 'object' ? vorschau : {}
+ const { rows, hasBalance } = forecastRows(v.posten, kontostandCents)
+ const notes = forecastNotes({ kontostandCents, stripeDown, abosFehler, hasRows: rows.length > 0 })
+ const assumption = typeof v.annahme === 'string' ? v.annahme.trim() : ''
+
+ return (
+
+ {rows.length === 0 ? (
+
+ ) : (
+
+
+ Erwartete Zahlungen der nächsten 30 Tage
+
+
+ | Datum |
+ Posten |
+ Sicherheit |
+ Betrag |
+ {hasBalance && (
+ <>
+ Saldo sicher |
+ Saldo erwartet |
+ >
+ )}
+
+
+
+ {rows.map((row) => (
+
+ | {row.dateText} |
+ {row.label} |
+
+ {row.sicherheit}
+ |
+ {row.amountText} |
+ {hasBalance && (
+ <>
+
+ {row.saldoSicherText}
+ |
+
+ {row.saldoErwartetText}
+ |
+ >
+ )}
+
+ ))}
+
+
+
+ )}
+ {(notes.length > 0 || assumption) && (
+
+ {notes.map((note) => (
+
+ {note.text}
+
+ ))}
+ {assumption &&
{assumption}
}
+
+ )}
+
+ )
+}
diff --git a/src/components/finance/TaxCard.jsx b/src/components/finance/TaxCard.jsx
new file mode 100644
index 0000000..e512749
--- /dev/null
+++ b/src/components/finance/TaxCard.jsx
@@ -0,0 +1,49 @@
+import { Card } from '../ui'
+import { taxView } from '../../lib/financeView'
+
+/**
+ * Block 4: „Steuer“. Rücklage (Betrag und woraus sie sich ergibt), Kleinunternehmer-Zähler gegen die Grenzen,
+ * Vorjahr (nur wenn eingetragen), Gewerbesteuer-Hinweis (nur wenn nötig) und die nächste ESt-Vorauszahlung
+ * (nur wenn eingestellt). Ist der Zähler unbekannt (Stripe nicht erreichbar), steht das als Text da, nie eine 0.
+ */
+export default function TaxCard({ steuer }) {
+ const v = taxView(steuer)
+ return (
+
+
+
+
+ Steuer-Rücklage
+ {v.reserve.amountText}
+
+ {v.reserve.basisText &&
{v.reserve.basisText}
}
+
+
+
+
Kleinunternehmer (§ 19 UStG)
+
{v.small.counterText}
+ {v.small.shareText &&
{v.small.shareText}
}
+
{v.small.hardText}
+ {v.small.previousText &&
{v.small.previousText}
}
+
+
+ {v.gewerbeText && (
+
+
Gewerbesteuer
+
{v.gewerbeText}
+
+ )}
+
+ {v.prepayment && (
+
+
+ Nächste ESt-Vorauszahlung
+ {v.prepayment.amountText}
+
+
am {v.prepayment.dateText}
+
+ )}
+
+
+ )
+}
diff --git a/src/lib/financeFormat.js b/src/lib/financeFormat.js
index 7d32d99..9d05b38 100644
--- a/src/lib/financeFormat.js
+++ b/src/lib/financeFormat.js
@@ -43,6 +43,13 @@ export function signedEuro(cents) {
return cents > 0 && text !== '0,00 €' ? `+${text}` : text
}
+/** 30 -> '30', 27.5 -> '27,5', 0.32 (1 Stelle) -> '0,3'; höchstens `maxDigits` Nachkommastellen, ohne überflüssige Nullen; ungültig -> '–'. */
+export function percentDe(value, maxDigits = 2) {
+ if (!isNum(value)) return PLACEHOLDER
+ const rounded = Number(value.toFixed(maxDigits))
+ return String(rounded).replace('-', MINUS).replace('.', ',')
+}
+
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/
const berlinDate = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
diff --git a/src/lib/financeView.js b/src/lib/financeView.js
index 08b0e73..c3b5805 100644
--- a/src/lib/financeView.js
+++ b/src/lib/financeView.js
@@ -1,4 +1,4 @@
-// Reine Darstellungs-Helfer fuer Statuszeile, Liquiditaetskopf und Aufgabenliste der Finanzen-Seite
+// 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.
import {
INTERVAL_LABEL,
@@ -6,6 +6,7 @@ import {
euro,
euroWhole,
nextIntervalDate,
+ percentDe,
signedEuro,
sinceLabel,
ymdBerlin,
@@ -216,3 +217,176 @@ export function planTodoAction(aktion) {
return planError('Diese Aktion ist nicht bekannt.')
}
}
+
+// --- Vorschau 30 Tage (Block 3) ----------------------------------------------------------------------------
+
+/**
+ * Zeilen der Vorschau mit laufendem Saldo beider Linien, beginnend beim Kontostand: „sicher“ zählt nur die
+ * sicheren Posten, „erwartet“ alle (wie buildForecast im Portal, daher passen die Tiefststände). Die Reihenfolge
+ * des Servers bleibt. Ohne Kontostand (null) gibt es keine Salden (hasBalance false, Saldo-Felder null / '–').
+ * Je Zeile: { key, dateText, label, sicherheit ('sicher'|'erwartet'), amountCents, amountText,
+ * saldoSicherCents, saldoSicherText, saldoSicherNegative, saldoErwartetCents, saldoErwartetText, saldoErwartetNegative }.
+ */
+export function forecastRows(posten, kontostandCents) {
+ const items = Array.isArray(posten) ? posten.filter((p) => p && typeof p === 'object' && isNum(p.amountCents)) : []
+ const hasBalance = isNum(kontostandCents)
+ let sicher = hasBalance ? kontostandCents : null
+ let erwartet = hasBalance ? kontostandCents : null
+ const rows = items.map((p, index) => {
+ const sure = p.sicherheit === 'sicher'
+ if (hasBalance) {
+ erwartet += p.amountCents
+ if (sure) sicher += p.amountCents
+ }
+ return {
+ key: [index, p.date || '', p.label || ''].join('|'),
+ dateText: dateDe(p.date),
+ label: text(p.label) || 'Zahlung',
+ sicherheit: sure ? 'sicher' : 'erwartet',
+ amountCents: p.amountCents,
+ amountText: signedEuro(p.amountCents),
+ saldoSicherCents: sicher,
+ saldoSicherText: euro(sicher),
+ saldoSicherNegative: hasBalance && sicher < 0,
+ saldoErwartetCents: erwartet,
+ saldoErwartetText: euro(erwartet),
+ saldoErwartetNegative: hasBalance && erwartet < 0,
+ }
+ })
+ return { rows, hasBalance }
+}
+
+/**
+ * Hinweise unter der Vorschau als [{ key, text }]: kein Kontostand (keine Salden), Stripe nicht erreichbar
+ * (Rechnungen und Auszahlungen fehlen), Abos nicht geladen (Verlängerungen fehlen).
+ */
+export function forecastNotes({ kontostandCents, stripeDown = false, abosFehler = false, hasRows = false } = {}) {
+ const notes = []
+ if (hasRows && !isNum(kontostandCents)) {
+ notes.push({
+ key: 'kontostand',
+ text: 'Ohne Kontostand zeigt die Vorschau keine Salden. Sobald die Bank verbunden und abgerufen ist, erscheinen sie hier.',
+ })
+ }
+ if (stripeDown) {
+ notes.push({
+ key: 'stripe',
+ text: 'Stripe war nicht erreichbar: Offene Rechnungen und Stripe-Auszahlungen fehlen in dieser Vorschau.',
+ })
+ }
+ if (abosFehler) {
+ notes.push({ key: 'abos', text: 'Abo-Verlängerungen fehlen, weil die Abos nicht geladen werden konnten.' })
+ }
+ return notes
+}
+
+// --- Steuer (Block 4) --------------------------------------------------------------------------------------
+
+// Gesetzliche Werte (§ 19 UStG, § 11 GewStG) als Rückfall, falls der Server eine Grenze nicht mitschickt
+const KLEIN_LIMIT_CENTS = 2_500_000
+const KLEIN_HARD_LIMIT_CENTS = 10_000_000
+const GEWERBE_ALLOWANCE_CENTS = 2_450_000
+
+/**
+ * Texte der Steuerkarte aus `steuer`:
+ * { reserve: { amountText, basisText }, small: { known, counterText, shareText, hardText, previousText },
+ * gewerbeText, prepayment: { dateText, amountText } | null }.
+ * Ist der Kleinunternehmer-Zähler null (Stripe nicht erreichbar), steht „unbekannt“ statt einer Zahl.
+ */
+export function taxView(steuer) {
+ const s = steuer && typeof steuer === 'object' ? steuer : {}
+ const k = s.kleinunternehmer && typeof s.kleinunternehmer === 'object' ? s.kleinunternehmer : {}
+ const year = isNum(s.jahr) ? s.jahr : null
+ const limit = isNum(k.grenzeFolgejahrCents) ? k.grenzeFolgejahrCents : KLEIN_LIMIT_CENTS
+ const hard = isNum(k.grenzeHartCents) ? k.grenzeHartCents : KLEIN_HARD_LIMIT_CENTS
+ const known = isNum(k.jahrCents)
+
+ const basisKnown = isNum(s.prozent) && isNum(s.gewinnCents)
+ const reserve = {
+ amountText: euro(s.ruecklageCents),
+ basisText: basisKnown ? `${percentDe(s.prozent)} % von ${euro(s.gewinnCents)} Gewinn${year !== null ? ` ${year}` : ''}` : '',
+ }
+
+ const previous = isNum(k.vorjahrCents)
+ ? `Vorjahr: ${euro(k.vorjahrCents)}${k.vorjahrCents > limit ? ` (über der Grenze von ${euroWhole(limit)})` : ''}`
+ : null
+
+ const prepay = s.naechsteVorauszahlung
+ const prepayment =
+ prepay && typeof prepay === 'object' && isNum(prepay.amountCents)
+ ? { dateText: dateDe(prepay.date), amountText: euro(prepay.amountCents) }
+ : null
+
+ return {
+ reserve,
+ small: {
+ known,
+ counterText: known ? `${euro(k.jahrCents)} von ${euroWhole(limit)}${year !== null ? ` (${year})` : ''}` : 'unbekannt (Stripe nicht erreichbar)',
+ shareText: known && isNum(k.anteilFolgejahr) ? `${percentDe(k.anteilFolgejahr * 100, 1)} % der Grenze` : null,
+ hardText: `Grenze ${euroWhole(hard)}: darüber endet die Regelung sofort.`,
+ previousText: previous,
+ },
+ gewerbeText:
+ s.gewerbesteuerHinweis === true
+ ? `Der Gewinn liegt über dem Gewerbesteuer-Freibetrag für Personengesellschaften (${euroWhole(GEWERBE_ALLOWANCE_CENTS)}). Es kann Gewerbesteuer anfallen.`
+ : null,
+ prepayment,
+ }
+}
+
+// --- Abos (Block 5) ----------------------------------------------------------------------------------------
+
+const PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
+const ABO_STATUS = {
+ active: { label: 'aktiv', tone: 'ok' },
+ trialing: { label: 'Testphase', tone: 'info' },
+ past_due: { label: 'Zahlung fehlgeschlagen', tone: 'danger' },
+ unpaid: { label: 'unbezahlt', tone: 'danger' },
+}
+const ABOS_FAILED = 'Abos konnten nicht geladen werden.'
+const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key)
+
+/** Katalogname eines Abo-Schlüssels (hosting -> „Hosting-Abo“); ohne Schlüssel „Abo“, unbekannter Schlüssel bleibt stehen. */
+export function productLabel(key) {
+ const k = text(key)
+ if (!k) return 'Abo'
+ return has(PRODUCT_LABEL, k) ? PRODUCT_LABEL[k] : k
+}
+
+/** Stripe-Status eines Abos -> { label, tone }; der Text steht immer dabei, nicht nur die Farbe. */
+export function aboStatus(status) {
+ const s = text(status)
+ if (has(ABO_STATUS, s)) return ABO_STATUS[s]
+ return { label: s || 'unbekannt', tone: 'muted' }
+}
+
+/**
+ * Texte der Abo-Karte: { error, monthlyText, payingText, atRiskText (null bis auf > 0), rows }.
+ * `fehler` (Abos nicht ladbar) gibt den Text des Servers und keine Zahlen. rows: { key, customer, product,
+ * statusLabel, tone, amountText, nextText } in Serverreihenfolge (Zahlungsprobleme zuerst).
+ */
+export function aboView(abos) {
+ const a = abos && typeof abos === 'object' ? abos : {}
+ if (a.fehler) {
+ return { error: text(a.fehler) || ABOS_FAILED, monthlyText: '–', payingText: '–', atRiskText: null, rows: [] }
+ }
+ const list = Array.isArray(a.liste) ? a.liste.filter((x) => x && typeof x === 'object') : []
+ return {
+ error: '',
+ monthlyText: euro(a.monatlichCents),
+ payingText: isNum(a.zahlendeKunden) ? String(a.zahlendeKunden) : '–',
+ atRiskText: isNum(a.gefaehrdetCents) && a.gefaehrdetCents > 0 ? euro(a.gefaehrdetCents) : null,
+ rows: list.map((abo, index) => {
+ const status = aboStatus(abo.status)
+ return {
+ key: [index, abo.customerId || '', abo.produkt || ''].join('|'),
+ customer: text(abo.kunde) || 'Unbekannter Kunde',
+ product: productLabel(abo.produkt),
+ statusLabel: status.label,
+ tone: status.tone,
+ amountText: euro(abo.betragCents),
+ nextText: dateDe(abo.naechsteAbbuchung),
+ }
+ }),
+ }
+}
diff --git a/src/pages/FinancePage.jsx b/src/pages/FinancePage.jsx
index adf77c8..04fa968 100644
--- a/src/pages/FinancePage.jsx
+++ b/src/pages/FinancePage.jsx
@@ -5,6 +5,9 @@ import { PageHeader, Card, Button } from '../components/ui'
import StatusLine from '../components/finance/StatusLine'
import LiquidityHead from '../components/finance/LiquidityHead'
import TodoList from '../components/finance/TodoList'
+import ForecastTable from '../components/finance/ForecastTable'
+import TaxCard from '../components/finance/TaxCard'
+import AboCard from '../components/finance/AboCard'
import { useFinanceOverview } from '../hooks/useFinanceOverview'
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
import { planTodoAction, safeUrl } from '../lib/financeView'
@@ -100,6 +103,18 @@ export default function FinancePage() {
+
+
+
+
+
)
}
diff --git a/src/styles/global.css b/src/styles/global.css
index 5a91e05..8eea7d0 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1205,3 +1205,80 @@ textarea.form-control {
.fin-todo { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); }
.fin-todo .ui-btn { justify-self: start; }
}
+
+/* Bloecke 3-5: Vorschau, Steuer, Abos */
+.fin-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+.fin-neg { color: var(--danger); }
+.fin-cols { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-5); align-items: start; }
+@media (min-width: 1100px) {
+ .fin-cols { grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr); }
+ .fin-cols-half { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+/* Block 3: Vorschau (Tabelle; in einer schmalen Karte wird jede Zeile zweizeilig) */
+.fin-forecast-card { container-type: inline-size; overflow: hidden; }
+.fin-table-wrap { overflow-x: auto; }
+.fin-forecast th, .fin-forecast td { padding: 9px 10px; vertical-align: baseline; }
+.fin-forecast th.fin-amount { text-align: right; }
+.fin-forecast th:first-child, .fin-forecast td:first-child { padding-left: var(--sp-4); }
+.fin-forecast th:last-child, .fin-forecast td:last-child { padding-right: var(--sp-4); }
+.fin-forecast tbody tr:last-child td { border-bottom: none; }
+.fin-forecast-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
+.fin-forecast-label { overflow-wrap: anywhere; }
+.fin-forecast-foot { display: grid; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
+.fin-forecast-foot .fin-hint svg { vertical-align: -2px; }
+@container (max-width: 560px) {
+ .fin-forecast thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+ .fin-forecast, .fin-forecast tbody { display: block; }
+ .fin-forecast tr {
+ display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px 0; align-items: baseline;
+ grid-template-areas: "date date kind kind amount amount" "label label label label label label" "sicher sicher sicher erwartet erwartet erwartet";
+ padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
+ }
+ .fin-forecast-nobal tr { grid-template-areas: "date date kind kind amount amount" "label label label label label label"; }
+ .fin-forecast tbody tr:last-child { border-bottom: none; }
+ .fin-forecast td, .fin-forecast td:first-child, .fin-forecast td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
+ .fin-forecast tbody tr:hover td { background: transparent; }
+ .fin-forecast-date { grid-area: date; }
+ .fin-forecast-kind { grid-area: kind; }
+ .fin-forecast-amount { grid-area: amount; font-weight: 700; }
+ .fin-forecast-label { grid-area: label; }
+ .fin-forecast td.fin-forecast-sicher, .fin-forecast td.fin-forecast-erwartet {
+ display: flex; flex-wrap: wrap; align-items: baseline; column-gap: var(--sp-2); font-size: 13px;
+ }
+ .fin-forecast-sicher { grid-area: sicher; }
+ .fin-forecast-erwartet { grid-area: erwartet; }
+ .fin-forecast td.fin-forecast-sicher { padding-right: var(--sp-3); }
+ .fin-forecast td.fin-forecast-erwartet { padding-left: var(--sp-3); }
+ .fin-saldo { margin-left: auto; }
+ .fin-forecast td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-size: 12px; font-weight: 500; white-space: nowrap; }
+}
+
+/* Block 4: Steuer */
+.fin-facts { display: grid; gap: var(--sp-4); }
+.fin-fact { display: grid; gap: 3px; padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border); }
+.fin-fact:last-child { padding-bottom: 0; border-bottom: none; }
+.fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); }
+.fin-fact-label { color: var(--text-muted); font-size: 13px; }
+.fin-fact-big { font-size: 24px; font-weight: 800; }
+.fin-fact-strong { font-size: 16px; font-weight: 700; }
+.fin-fact-value { font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.fin-fact-unknown { color: var(--warn); }
+.fin-fact-sub { font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
+.fin-fact-sub-strong { color: var(--text); }
+
+/* Block 5: Abos */
+.fin-abo-card { overflow: hidden; }
+.fin-stats { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px var(--sp-3); align-items: baseline; padding: var(--sp-4); border-bottom: 1px solid var(--border); }
+.fin-stat-value { font-size: 20px; font-weight: 800; }
+.fin-stat-label { color: var(--text-muted); }
+.fin-stat-label.fin-flag { color: var(--danger); }
+.fin-stat-label svg { vertical-align: -2px; }
+.fin-abos { list-style: none; margin: 0; padding: 0; }
+.fin-abo {
+ display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-3); align-items: baseline;
+ padding: 11px var(--sp-4); border-bottom: 1px solid var(--border);
+}
+.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; }
diff --git a/tests/unit/financeFormat.test.mjs b/tests/unit/financeFormat.test.mjs
index 71855ab..cad12df 100644
--- a/tests/unit/financeFormat.test.mjs
+++ b/tests/unit/financeFormat.test.mjs
@@ -18,6 +18,7 @@ const {
nextIntervalDate,
ymdBerlin,
INTERVAL_LABEL,
+ percentDe,
} = await loadModule('src/lib/financeFormat.js')
const MINUS = '−'
@@ -298,3 +299,20 @@ test('ymdBerlin: Berliner Kalendertag eines Zeitpunkts', () => {
assert.equal(ymdBerlin(null), null)
assert.equal(ymdBerlin(NaN), null)
})
+
+test('percentDe: Dezimalkomma, überflüssige Nullen entfallen', () => {
+ assert.equal(percentDe(30), '30')
+ assert.equal(percentDe(27.5), '27,5')
+ assert.equal(percentDe(30.25), '30,25')
+ assert.equal(percentDe(0), '0')
+ assert.equal(percentDe(100), '100')
+ assert.equal(percentDe(0.32, 1), '0,3')
+ assert.equal(percentDe(70.4, 1), '70,4')
+ assert.equal(percentDe(30.256), '30,26')
+ assert.equal(percentDe(-12.5), `${MINUS}12,5`)
+ assert.equal(percentDe(-0.001), '0')
+})
+
+test('percentDe: Platzhalter für fehlende Werte', () => {
+ for (const v of [null, undefined, NaN, Infinity, 'x']) assert.equal(percentDe(v), '–')
+})
diff --git a/tests/unit/financeView.test.mjs b/tests/unit/financeView.test.mjs
index 609bfd7..b85637a 100644
--- a/tests/unit/financeView.test.mjs
+++ b/tests/unit/financeView.test.mjs
@@ -17,6 +17,12 @@ const {
todoButtonLabel,
fixedCostFromSuggestion,
planTodoAction,
+ forecastRows,
+ forecastNotes,
+ taxView,
+ aboView,
+ productLabel,
+ aboStatus,
} = await loadModule('src/lib/financeView.js')
const MINUS = '−'
@@ -256,3 +262,291 @@ test('planTodoAction: fehlende Angaben und unbekannte Aktionen geben eine Meldun
assert.ok(plan.message.length > 0)
}
})
+
+// --- Vorschau 30 Tage (Block 3) -----------------------------------------------------------------------------
+
+const posten = () => [
+ { date: '2026-10-03', label: 'Rechnung WK-0005 · Kunde Eins', amountCents: 16879, sicherheit: 'erwartet' },
+ { date: '2026-10-06', label: 'Hetzner', amountCents: -1000, sicherheit: 'sicher' },
+ { date: '2026-10-10', label: 'Stripe-Auszahlung', amountCents: 2500, sicherheit: 'sicher' },
+ { date: '2026-10-11', label: 'Miete', amountCents: -30000, sicherheit: 'sicher' },
+]
+
+test('forecastRows: laufende Salden, sicher zählt nur sichere Posten, erwartet alle', () => {
+ const { rows, hasBalance } = forecastRows(posten(), 10893)
+ assert.equal(hasBalance, true)
+ assert.deepEqual(rows.map((r) => r.saldoSicherCents), [10893, 9893, 12393, -17607])
+ assert.deepEqual(rows.map((r) => r.saldoErwartetCents), [27772, 26772, 29272, -728])
+})
+
+test('forecastRows: Texte (Datum TT.MM.JJJJ, Betrag mit Vorzeichen, Salden mit U+2212)', () => {
+ const { rows } = forecastRows(posten(), 10893)
+ assert.equal(rows[0].dateText, '03.10.2026')
+ assert.equal(rows[0].label, 'Rechnung WK-0005 · Kunde Eins')
+ assert.equal(rows[0].sicherheit, 'erwartet')
+ assert.equal(rows[0].amountText, '+168,79 €')
+ assert.equal(rows[0].saldoSicherText, '108,93 €')
+ assert.equal(rows[0].saldoErwartetText, '277,72 €')
+ assert.equal(rows[1].sicherheit, 'sicher')
+ assert.equal(rows[1].amountText, `${MINUS}10,00 €`)
+ assert.equal(rows[3].amountText, `${MINUS}300,00 €`)
+ assert.equal(rows[3].saldoSicherText, `${MINUS}176,07 €`)
+ assert.equal(rows[3].saldoErwartetText, `${MINUS}7,28 €`)
+ assert.equal(rows[3].saldoSicherNegative, true)
+ assert.equal(rows[0].saldoSicherNegative, false)
+})
+
+test('forecastRows: der tiefste laufende Saldo passt zu minSicher/minErwartet des Servers', () => {
+ const { rows } = forecastRows(posten(), 10893)
+ assert.equal(Math.min(10893, ...rows.map((r) => r.saldoSicherCents)), -17607)
+ assert.equal(Math.min(10893, ...rows.map((r) => r.saldoErwartetCents)), -728)
+})
+
+test('forecastRows: ohne Kontostand keine Salden', () => {
+ for (const none of [null, undefined, NaN]) {
+ const { rows, hasBalance } = forecastRows(posten(), none)
+ assert.equal(hasBalance, false)
+ assert.equal(rows.length, 4)
+ assert.ok(rows.every((r) => r.saldoSicherCents === null && r.saldoErwartetCents === null))
+ assert.ok(rows.every((r) => r.saldoSicherText === '–' && r.saldoErwartetText === '–'))
+ }
+})
+
+test('forecastRows: Kontostand 0 ist ein Kontostand', () => {
+ const { rows, hasBalance } = forecastRows(posten().slice(1, 2), 0)
+ assert.equal(hasBalance, true)
+ assert.deepEqual(rows.map((r) => r.saldoSicherCents), [-1000])
+})
+
+test('forecastRows: leer, kaputte Einträge und unbekannte Sicherheit (zählt als erwartet)', () => {
+ for (const none of [null, undefined, [], 'x', {}]) assert.deepEqual(forecastRows(none, 100).rows, [])
+ const { rows } = forecastRows(
+ [
+ null,
+ { date: '2026-10-03', label: 'ohne Betrag', amountCents: null, sicherheit: 'sicher' },
+ { date: '2026-10-04', label: ' ', amountCents: 500, sicherheit: 'irgendwas' },
+ ],
+ 1000
+ )
+ assert.equal(rows.length, 1)
+ assert.equal(rows[0].label, 'Zahlung')
+ assert.equal(rows[0].sicherheit, 'erwartet')
+ assert.equal(rows[0].saldoSicherCents, 1000)
+ assert.equal(rows[0].saldoErwartetCents, 1500)
+})
+
+test('forecastRows: Serverreihenfolge bleibt, Schlüssel sind eindeutig', () => {
+ const same = { date: '2026-10-03', label: 'Hetzner', amountCents: -100, sicherheit: 'sicher' }
+ const { rows } = forecastRows([same, same], 500)
+ assert.deepEqual(rows.map((r) => r.saldoSicherCents), [400, 300])
+ assert.notEqual(rows[0].key, rows[1].key)
+})
+
+test('forecastNotes: Hinweise zu Kontostand, Stripe und Abos', () => {
+ assert.deepEqual(forecastNotes({ kontostandCents: 100, stripeDown: false, abosFehler: false, hasRows: true }), [])
+ const all = forecastNotes({ kontostandCents: null, stripeDown: true, abosFehler: true, hasRows: true })
+ assert.deepEqual(all.map((n) => n.key), ['kontostand', 'stripe', 'abos'])
+ assert.equal(all[2].text, 'Abo-Verlängerungen fehlen, weil die Abos nicht geladen werden konnten.')
+ assert.match(all[0].text, /Ohne Kontostand/)
+ assert.match(all[1].text, /Stripe/)
+ // ohne Zeilen gibt es nichts zu saldieren
+ assert.deepEqual(forecastNotes({ kontostandCents: null, stripeDown: false, abosFehler: false, hasRows: false }), [])
+ assert.deepEqual(forecastNotes({ kontostandCents: null, stripeDown: false, abosFehler: true, hasRows: false }).map((n) => n.key), ['abos'])
+ assert.deepEqual(forecastNotes({}).map((n) => n.key), [])
+})
+
+// --- Steuer (Block 4) ---------------------------------------------------------------------------------------
+
+const steuer = (extra = {}, klein = {}) => ({
+ jahr: 2026,
+ einnahmenCents: 16879,
+ ausgabenCents: 5986,
+ gewinnCents: 10893,
+ ruecklageCents: 3268,
+ prozent: 30,
+ gewerbesteuerHinweis: false,
+ kleinunternehmer: {
+ jahrCents: 8000,
+ vorjahrCents: null,
+ grenzeFolgejahrCents: 2500000,
+ grenzeHartCents: 10000000,
+ anteilFolgejahr: 0.0032,
+ ...klein,
+ },
+ naechsteVorauszahlung: null,
+ ...extra,
+})
+
+test('taxView: Rücklage mit Prozentsatz, Gewinn und Jahr aus den echten Werten', () => {
+ const v = taxView(steuer())
+ assert.equal(v.reserve.amountText, '32,68 €')
+ assert.equal(v.reserve.basisText, '30 % von 108,93 € Gewinn 2026')
+ const other = taxView(steuer({ jahr: 2027, prozent: 27.5, gewinnCents: 123456789, ruecklageCents: 100 }))
+ assert.equal(other.reserve.basisText, '27,5 % von 1.234.567,89 € Gewinn 2027')
+})
+
+test('taxView: Verlust zeigt den negativen Gewinn mit U+2212', () => {
+ const v = taxView(steuer({ gewinnCents: -5000, ruecklageCents: 0 }))
+ assert.equal(v.reserve.amountText, '0,00 €')
+ assert.equal(v.reserve.basisText, `30 % von ${MINUS}50,00 € Gewinn 2026`)
+})
+
+test('taxView: Kleinunternehmer-Zähler „ von 25.000 € (2026)“ und harte Grenze', () => {
+ const v = taxView(steuer())
+ assert.equal(v.small.known, true)
+ assert.equal(v.small.counterText, '80,00 € von 25.000 € (2026)')
+ assert.equal(v.small.shareText, '0,3 % der Grenze')
+ assert.equal(v.small.hardText, 'Grenze 100.000 €: darüber endet die Regelung sofort.')
+ assert.equal(v.small.previousText, null)
+ assert.equal(taxView(steuer({}, { anteilFolgejahr: 0.704 })).small.shareText, '70,4 % der Grenze')
+ assert.equal(taxView(steuer({}, { jahrCents: 0, anteilFolgejahr: 0 })).small.counterText, '0,00 € von 25.000 € (2026)')
+})
+
+test('taxView: Stripe nicht erreichbar -> unbekannt statt 0', () => {
+ const v = taxView(steuer({}, { jahrCents: null, anteilFolgejahr: null }))
+ assert.equal(v.small.known, false)
+ assert.equal(v.small.counterText, 'unbekannt (Stripe nicht erreichbar)')
+ assert.equal(v.small.shareText, null)
+ assert.equal(v.small.hardText, 'Grenze 100.000 €: darüber endet die Regelung sofort.')
+ // nur der Zähler ist unbekannt, der Rest der Karte bleibt
+ assert.equal(v.reserve.amountText, '32,68 €')
+})
+
+test('taxView: Vorjahr nur wenn gesetzt, über der Grenze mit Hinweis', () => {
+ assert.equal(taxView(steuer({}, { vorjahrCents: 1200000 })).small.previousText, 'Vorjahr: 12.000,00 €')
+ assert.equal(taxView(steuer({}, { vorjahrCents: 0 })).small.previousText, 'Vorjahr: 0,00 €')
+ assert.equal(taxView(steuer({}, { vorjahrCents: 2500000 })).small.previousText, 'Vorjahr: 25.000,00 €')
+ assert.equal(
+ taxView(steuer({}, { vorjahrCents: 2500001 })).small.previousText,
+ 'Vorjahr: 25.000,01 € (über der Grenze von 25.000 €)'
+ )
+})
+
+test('taxView: Gewerbesteuer-Hinweis nur mit gewerbesteuerHinweis', () => {
+ assert.equal(taxView(steuer()).gewerbeText, null)
+ const t = taxView(steuer({ gewerbesteuerHinweis: true })).gewerbeText
+ assert.match(t, /Gewerbesteuer/)
+ assert.match(t, /24\.500 €/)
+})
+
+test('taxView: nächste ESt-Vorauszahlung nur wenn gesetzt', () => {
+ assert.equal(taxView(steuer()).prepayment, null)
+ assert.deepEqual(taxView(steuer({ naechsteVorauszahlung: { date: '2026-12-10', amountCents: 100000 } })).prepayment, {
+ dateText: '10.12.2026',
+ amountText: '1.000,00 €',
+ })
+})
+
+test('taxView: fehlende Teile werden nicht zu Müll', () => {
+ const v = taxView(null)
+ assert.equal(v.reserve.amountText, '–')
+ assert.equal(v.reserve.basisText, '')
+ assert.equal(v.small.known, false)
+ assert.equal(v.gewerbeText, null)
+ assert.equal(v.prepayment, null)
+ // ohne Grenzen aus dem Server gelten die gesetzlichen Werte
+ const bare = taxView({ jahr: 2026, kleinunternehmer: { jahrCents: 100 } })
+ assert.equal(bare.small.counterText, '1,00 € von 25.000 € (2026)')
+ assert.equal(bare.small.hardText, 'Grenze 100.000 €: darüber endet die Regelung sofort.')
+})
+
+// --- Abos (Block 5) -----------------------------------------------------------------------------------------
+
+const abos = (extra = {}) => ({
+ monatlichCents: 7000,
+ zahlendeKunden: 1,
+ gefaehrdetCents: 5000,
+ liste: [
+ { customerId: 'c2', kunde: 'Zwei GmbH', produkt: 'wartung', status: 'past_due', betragCents: 5000, naechsteAbbuchung: null },
+ { customerId: 'c1', kunde: 'Kunde Eins', produkt: 'hosting', status: 'active', betragCents: 2000, naechsteAbbuchung: '2026-10-21' },
+ ],
+ ...extra,
+})
+
+test('aboView: Kennzahlen und Liste', () => {
+ const v = aboView(abos())
+ assert.equal(v.error, '')
+ assert.equal(v.monthlyText, '70,00 €')
+ assert.equal(v.payingText, '1')
+ assert.equal(v.atRiskText, '50,00 €')
+ assert.equal(v.rows.length, 2)
+ assert.deepEqual(
+ { ...v.rows[0], key: undefined },
+ {
+ key: undefined,
+ customer: 'Zwei GmbH',
+ product: 'Wartungs-Abo',
+ statusLabel: 'Zahlung fehlgeschlagen',
+ tone: 'danger',
+ amountText: '50,00 €',
+ nextText: '–',
+ }
+ )
+ assert.deepEqual(
+ { ...v.rows[1], key: undefined },
+ { key: undefined, customer: 'Kunde Eins', product: 'Hosting-Abo', statusLabel: 'aktiv', tone: 'ok', amountText: '20,00 €', nextText: '21.10.2026' }
+ )
+ assert.notEqual(v.rows[0].key, v.rows[1].key)
+})
+
+test('aboView: „gefährdet“ nur über 0', () => {
+ assert.equal(aboView(abos({ gefaehrdetCents: 0 })).atRiskText, null)
+ assert.equal(aboView(abos({ gefaehrdetCents: null })).atRiskText, null)
+ assert.equal(aboView(abos({ gefaehrdetCents: 1 })).atRiskText, '0,01 €')
+})
+
+test('aboView: Ladefehler zeigt den Text des Servers, keine Zahlen', () => {
+ const v = aboView({ monatlichCents: null, zahlendeKunden: null, gefaehrdetCents: null, liste: [], fehler: 'Abos konnten nicht geladen werden.' })
+ assert.equal(v.error, 'Abos konnten nicht geladen werden.')
+ assert.equal(v.monthlyText, '–')
+ assert.equal(v.payingText, '–')
+ assert.equal(v.atRiskText, null)
+ assert.deepEqual(v.rows, [])
+ // leerer Text -> Standardtext
+ assert.equal(aboView({ fehler: true, liste: [] }).error, 'Abos konnten nicht geladen werden.')
+})
+
+test('aboView: leer oder fehlend', () => {
+ for (const none of [null, undefined, {}, { liste: [] }, { liste: 'x' }]) {
+ const v = aboView(none)
+ assert.deepEqual(v.rows, [])
+ assert.equal(v.error, '')
+ }
+ assert.equal(aboView({ monatlichCents: 0, zahlendeKunden: 0, gefaehrdetCents: 0, liste: [] }).monthlyText, '0,00 €')
+})
+
+test('aboView: unbekannter Kunde, unbekanntes Produkt und unbekannter Status', () => {
+ const v = aboView({
+ liste: [
+ { customerId: '', kunde: '', produkt: '', status: 'trialing', betragCents: 100, naechsteAbbuchung: '2026-11-01' },
+ { customerId: 'c3', kunde: ' Drei ', produkt: 'sonst', status: 'unpaid', betragCents: null, naechsteAbbuchung: 'kaputt' },
+ { customerId: 'c4', kunde: 'Vier', produkt: 'hosting', status: 'paused', betragCents: 0, naechsteAbbuchung: null },
+ null,
+ ],
+ })
+ assert.equal(v.rows.length, 3)
+ assert.equal(v.rows[0].customer, 'Unbekannter Kunde')
+ assert.equal(v.rows[0].product, 'Abo')
+ assert.equal(v.rows[0].statusLabel, 'Testphase')
+ assert.equal(v.rows[0].tone, 'info')
+ assert.equal(v.rows[1].customer, 'Drei')
+ assert.equal(v.rows[1].product, 'sonst')
+ assert.equal(v.rows[1].statusLabel, 'unbezahlt')
+ assert.equal(v.rows[1].tone, 'danger')
+ assert.equal(v.rows[1].amountText, '–')
+ assert.equal(v.rows[1].nextText, '–')
+ assert.equal(v.rows[2].statusLabel, 'paused')
+ assert.equal(v.rows[2].tone, 'muted')
+})
+
+test('productLabel und aboStatus', () => {
+ assert.equal(productLabel('hosting'), 'Hosting-Abo')
+ assert.equal(productLabel('wartung'), 'Wartungs-Abo')
+ assert.equal(productLabel(''), 'Abo')
+ assert.equal(productLabel(undefined), 'Abo')
+ assert.equal(productLabel('x_y'), 'x_y')
+ assert.deepEqual(aboStatus('active'), { label: 'aktiv', tone: 'ok' })
+ assert.deepEqual(aboStatus('past_due'), { label: 'Zahlung fehlgeschlagen', tone: 'danger' })
+ assert.deepEqual(aboStatus('weird'), { label: 'weird', tone: 'muted' })
+ assert.deepEqual(aboStatus(''), { label: 'unbekannt', tone: 'muted' })
+})