From 3826f1ab7298d2c880d7a36c28c1912b318cb481 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:50:04 +0000 Subject: [PATCH] Finanzen: Vorschau, Steuer und Abos Co-Authored-By: Claude Sonnet 5.5 --- src/components/finance/AboCard.jsx | 61 +++++ src/components/finance/ForecastTable.jsx | 84 +++++++ src/components/finance/TaxCard.jsx | 49 ++++ src/lib/financeFormat.js | 7 + src/lib/financeView.js | 176 +++++++++++++- src/pages/FinancePage.jsx | 15 ++ src/styles/global.css | 77 ++++++ tests/unit/financeFormat.test.mjs | 18 ++ tests/unit/financeView.test.mjs | 294 +++++++++++++++++++++++ 9 files changed, 780 insertions(+), 1 deletion(-) create mode 100644 src/components/finance/AboCard.jsx create mode 100644 src/components/finance/ForecastTable.jsx create mode 100644 src/components/finance/TaxCard.jsx 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 ( + +

+

+
+ ) + } + + return ( + +
+ {v.monthlyText} + Abo-Einnahmen pro Monat + {v.payingText} + zahlende Kunden + {v.atRiskText && ( + <> + {v.atRiskText} + + + + )} +
+ + {v.rows.length === 0 ? ( + + ) : ( +
    + {v.rows.map((row) => ( +
  • +
    + {row.customer} + · {row.product} +
    +
    {row.amountText}
    +
    + {row.statusLabel} + nächste Abbuchung: {row.nextText} +
    +
  • + ))} +
+ )} +
+ ) +} 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 ? ( + + ) : ( +
+ + + + + + + + + {hasBalance && ( + <> + + + + )} + + + + {rows.map((row) => ( + + + + + + {hasBalance && ( + <> + + + + )} + + ))} + +
Erwartete Zahlungen der nächsten 30 Tage
DatumPostenSicherheitBetragSaldo sicherSaldo erwartet
{row.dateText}{row.label} + {row.sicherheit} + {row.amountText} + {row.saldoSicherText} + + {row.saldoErwartetText} +
+
+ )} + {(notes.length > 0 || assumption) && ( +
+ {notes.map((note) => ( +

+

+ ))} + {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' }) +})