Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste

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

No files matched your search

+53
View File
@@ -15,6 +15,9 @@ const {
AGE_GROUP_LABEL,
pageOf,
canInvoice,
nextIntervalDate,
ymdBerlin,
INTERVAL_LABEL,
} = await loadModule('src/lib/financeFormat.js')
const MINUS = '−'
@@ -245,3 +248,53 @@ test('canInvoice: kein Kunde', () => {
assert.equal(canInvoice(undefined).ok, false)
assert.equal(typeof canInvoice(null).reason, 'string')
})
test('nextIntervalDate: ein Rhythmus nach dem Datum', () => {
assert.equal(nextIntervalDate('2026-09-27', 'monat'), '2026-10-27')
assert.equal(nextIntervalDate('2026-09-27', 'quartal'), '2026-12-27')
assert.equal(nextIntervalDate('2026-09-27', 'jahr'), '2027-09-27')
assert.equal(nextIntervalDate('2026-12-15', 'monat'), '2027-01-15')
assert.equal(nextIntervalDate('2026-11-15', 'quartal'), '2027-02-15')
})
test('nextIntervalDate: Monatsende wird begrenzt, nicht in den Folgemonat übergelaufen', () => {
assert.equal(nextIntervalDate('2026-01-31', 'monat'), '2026-02-28')
assert.equal(nextIntervalDate('2024-01-31', 'monat'), '2024-02-29')
assert.equal(nextIntervalDate('2026-03-31', 'monat'), '2026-04-30')
assert.equal(nextIntervalDate('2026-11-30', 'quartal'), '2027-02-28')
assert.equal(nextIntervalDate('2026-08-31', 'quartal'), '2026-11-30')
assert.equal(nextIntervalDate('2024-02-29', 'jahr'), '2025-02-28')
assert.equal(nextIntervalDate('2023-02-28', 'jahr'), '2024-02-28')
assert.equal(nextIntervalDate('1900-01-31', 'monat'), '1900-02-28') // 1900 ist kein Schaltjahr
assert.equal(nextIntervalDate('2000-01-31', 'monat'), '2000-02-29')
})
test('nextIntervalDate: ungültige Eingaben ergeben null', () => {
assert.equal(nextIntervalDate('2026-02-30', 'monat'), null)
assert.equal(nextIntervalDate('2026-13-01', 'monat'), null)
assert.equal(nextIntervalDate('2026-00-10', 'monat'), null)
assert.equal(nextIntervalDate('2026-04-31', 'monat'), null)
assert.equal(nextIntervalDate('27.09.2026', 'monat'), null)
assert.equal(nextIntervalDate('', 'monat'), null)
assert.equal(nextIntervalDate(null, 'monat'), null)
assert.equal(nextIntervalDate(undefined, 'monat'), null)
assert.equal(nextIntervalDate('2026-09-27', 'woche'), null)
assert.equal(nextIntervalDate('2026-09-27', undefined), null)
assert.equal(nextIntervalDate('2026-09-27', 'constructor'), null)
})
test('INTERVAL_LABEL: deutsche Rhythmus-Wörter wie im Portal', () => {
assert.deepEqual(INTERVAL_LABEL, { monat: 'monatlich', quartal: 'vierteljährlich', jahr: 'jährlich' })
})
test('ymdBerlin: Berliner Kalendertag eines Zeitpunkts', () => {
assert.equal(ymdBerlin('2026-10-01T10:55:21.691Z'), '2026-10-01')
assert.equal(ymdBerlin('2026-07-01T23:30:00.000Z'), '2026-07-02') // Sommerzeit: schon der nächste Tag
assert.equal(ymdBerlin('2099-03-29T00:00:00.000Z'), '2099-03-29')
assert.equal(ymdBerlin(new Date('2026-12-31T23:30:00Z')), '2027-01-01')
assert.equal(ymdBerlin(Date.UTC(2026, 9, 1, 12)), '2026-10-01')
assert.equal(ymdBerlin(''), null)
assert.equal(ymdBerlin('kein Datum'), null)
assert.equal(ymdBerlin(null), null)
assert.equal(ymdBerlin(NaN), null)
})
+258
View File
@@ -0,0 +1,258 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
INVOICES_ANCHOR,
TODO_LIMIT,
bankStatus,
stripeStatus,
statusItems,
breakdownRows,
lowPointLines,
visibleTodos,
urgencyOf,
todoKey,
safeUrl,
todoButtonLabel,
fixedCostFromSuggestion,
planTodoAction,
} = await loadModule('src/lib/financeView.js')
const MINUS = '−'
const NOW = Date.parse('2026-10-01T13:55:21.691Z') // 15:55 Berliner Zeit
const bank = (extra = {}) => ({
status: 'aktiv',
ibanMasked: 'DE00 •••• 9313',
validUntil: '2027-03-29T00:00:00.000Z',
lastSyncAt: '2026-10-01T10:55:21.691Z',
lastSyncOk: true,
lastError: '',
balanceBookedCents: 10893,
balanceAvailableCents: 9999,
balanceDate: '2026-10-01',
latestBookingDate: '2026-09-29',
...extra,
})
// --- Statuszeile ---------------------------------------------------------------------------------------------
test('bankStatus: Stand, jüngste Buchung und Zugang bis (Spec Block 0)', () => {
assert.deepEqual(bankStatus(bank(), NOW), {
ok: true,
text: 'Bank: Stand vor 3 Std. · jüngste Buchung 29.09.2026 · Zugang bis 29.03.2027',
})
})
test('bankStatus: ohne Verbindung', () => {
for (const none of [null, undefined, 'x']) {
assert.deepEqual(bankStatus(none, NOW), { ok: false, text: 'Bank: nicht verbunden' })
}
})
test('bankStatus: Abruf fehlgeschlagen zeigt den Fehler und weiter den Zugang', () => {
const r = bankStatus(bank({ lastSyncOk: false, lastError: 'Bank antwortet nicht' }), NOW)
assert.equal(r.ok, false)
assert.equal(r.text, 'Bank: Abruf fehlgeschlagen: Bank antwortet nicht · Zugang bis 29.03.2027')
assert.equal(bankStatus(bank({ lastSyncOk: false, lastError: '' }), NOW).text, 'Bank: Abruf fehlgeschlagen · Zugang bis 29.03.2027')
assert.equal(bankStatus(bank({ status: 'fehler', lastError: 'x' }), NOW).ok, false)
})
test('bankStatus: abgelaufener Zugang (Status oder Datum)', () => {
assert.deepEqual(bankStatus(bank({ status: 'abgelaufen' }), NOW), { ok: false, text: 'Bank: Zugang abgelaufen am 29.03.2027' })
const past = bankStatus(bank({ validUntil: '2026-09-30T00:00:00.000Z' }), NOW)
assert.deepEqual(past, { ok: false, text: 'Bank: Zugang abgelaufen am 30.09.2026' })
// am letzten Tag ist der Zugang noch gültig
assert.equal(bankStatus(bank({ validUntil: '2026-10-01T00:00:00.000Z' }), NOW).ok, true)
})
test('bankStatus: noch kein Abruf, keine Buchung, kein Datum', () => {
const r = bankStatus(bank({ lastSyncAt: null, latestBookingDate: null, validUntil: null }), NOW)
assert.deepEqual(r, { ok: true, text: 'Bank: noch kein Abruf' })
})
test('stripeStatus: Stand und Ausfall mit dem Text des Servers', () => {
assert.deepEqual(stripeStatus({ ok: true, abgerufenUm: '2026-10-01T13:55:00.000Z' }, NOW), { ok: true, text: 'Stripe: Stand gerade eben' })
assert.deepEqual(stripeStatus({ ok: false, fehler: 'Stripe ist gerade nicht erreichbar.', abgerufenUm: null }, NOW), {
ok: false,
text: 'Stripe: Abruf fehlgeschlagen: Stripe ist gerade nicht erreichbar.',
})
assert.equal(stripeStatus({ ok: false }, NOW).text, 'Stripe: Abruf fehlgeschlagen: Stripe ist gerade nicht erreichbar.')
assert.deepEqual(stripeStatus({ ok: true, abgerufenUm: null }, NOW), { ok: true, text: 'Stripe: Stand unbekannt' })
assert.deepEqual(stripeStatus(null, NOW), { ok: true, text: 'Stripe: Stand unbekannt' })
})
test('statusItems: Bank und Stripe, fehlender Stand ist kein Fehler im Code', () => {
const items = statusItems({ bank: null, stripe: { ok: true, abgerufenUm: '2026-10-01T13:00:00.000Z' } }, NOW)
assert.deepEqual(items.map((i) => i.key), ['bank', 'stripe'])
assert.equal(items[0].text, 'Bank: nicht verbunden')
assert.equal(items[1].text, 'Stripe: Stand vor 55 Min.')
assert.equal(statusItems(undefined, NOW).length, 2)
})
// --- Liquiditätskopf -----------------------------------------------------------------------------------------
test('breakdownRows: Kontostand minus Rücklage, Fixkosten und Puffer ergibt Verfügbar', () => {
const rows = breakdownRows({ kontostandCents: 10893, steuerRuecklageCents: 3268, fixkosten30Cents: 1449, mindestpufferCents: 2000 }, 4176)
assert.deepEqual(
rows.map((r) => [r.label, r.text]),
[
['Kontostand (gebucht)', '108,93 €'],
['Steuer-Rücklage', `${MINUS}32,68 €`],
['Fixkosten der nächsten 30 Tage', `${MINUS}14,49 €`],
['Mindestpuffer', `${MINUS}20,00 €`],
['Verfügbar nach Rücklagen', '41,76 €'],
]
)
assert.deepEqual(rows.map((r) => r.total), [false, false, false, false, true])
})
test('breakdownRows: Puffer 0 ohne Vorzeichen, negatives Ergebnis, fehlende Werte', () => {
const rows = breakdownRows({ kontostandCents: 1000, steuerRuecklageCents: 0, fixkosten30Cents: 5000, mindestpufferCents: 0 }, -4000)
assert.equal(rows[1].text, '0,00 €')
assert.equal(rows[4].text, `${MINUS}40,00 €`)
const none = breakdownRows(null, null)
assert.deepEqual(none.map((r) => r.text), ['–', '–', '–', '–', '–'])
})
test('lowPointLines: sicher und erwartet mit ganzen Euro und Datum', () => {
const [sicher, erwartet] = lowPointLines({
tiefsterSicher: { amountCents: 9444, date: '2026-10-11' },
tiefsterErwartet: { amountCents: 10893, date: '2026-10-01' },
})
assert.deepEqual(sicher, { key: 'sicher', label: 'sicher', amountText: '94 €', dateText: '11.10.2026', negative: false })
assert.deepEqual(erwartet, { key: 'erwartet', label: 'erwartet', amountText: '109 €', dateText: '01.10.2026', negative: false })
})
test('lowPointLines: negativ mit Minus U+2212, ohne Kontostand null', () => {
const [sicher] = lowPointLines({ tiefsterSicher: { amountCents: -12000, date: '2026-10-20' }, tiefsterErwartet: null })
assert.equal(sicher.amountText, `${MINUS}120 €`)
assert.equal(sicher.negative, true)
assert.deepEqual(lowPointLines({ tiefsterSicher: null, tiefsterErwartet: null }), [null, null])
assert.deepEqual(lowPointLines(undefined), [null, null])
})
// --- Zu erledigen --------------------------------------------------------------------------------------------
const todos = (n) => Array.from({ length: n }, (_, i) => ({ art: 'abo-zahlung', dringlichkeit: 'hoch', titel: `T${i}`, text: '', datum: '2026-10-01', aktion: { typ: 'kunde-oeffnen', customerId: `c${i}` } }))
test('visibleTodos: höchstens 7, Rest hinter „Alle N anzeigen“', () => {
assert.equal(TODO_LIMIT, 7)
const few = visibleTodos(todos(7))
assert.equal(few.shown.length, 7)
assert.equal(few.hidden, 0)
const many = visibleTodos(todos(12))
assert.equal(many.shown.length, 7)
assert.equal(many.total, 12)
assert.equal(many.hidden, 5)
assert.deepEqual(many.shown.map((t) => t.titel), ['T0', 'T1', 'T2', 'T3', 'T4', 'T5', 'T6'])
const all = visibleTodos(todos(12), true)
assert.equal(all.shown.length, 12)
assert.equal(all.hidden, 0)
})
test('visibleTodos: leer, ungültig, Nicht-Objekte werden ausgelassen', () => {
assert.deepEqual(visibleTodos([]), { shown: [], total: 0, hidden: 0 })
assert.deepEqual(visibleTodos(null), { shown: [], total: 0, hidden: 0 })
assert.deepEqual(visibleTodos(undefined), { shown: [], total: 0, hidden: 0 })
assert.equal(visibleTodos([null, 'x', 3, todos(1)[0]]).total, 1)
})
test('urgencyOf: Badge-Text und Ton, unbekannt gilt als Hinweis', () => {
assert.deepEqual(urgencyOf({ dringlichkeit: 'hoch' }), { label: 'Dringend', tone: 'danger' })
assert.deepEqual(urgencyOf({ dringlichkeit: 'mittel' }), { label: 'Bald', tone: 'warn' })
assert.deepEqual(urgencyOf({ dringlichkeit: 'info' }), { label: 'Hinweis', tone: 'muted' })
assert.deepEqual(urgencyOf({ dringlichkeit: 'x' }), { label: 'Hinweis', tone: 'muted' })
assert.deepEqual(urgencyOf(null), { label: 'Hinweis', tone: 'muted' })
})
test('todoKey: unterscheidet Aufgaben auch bei gleichem Titel', () => {
const [a, b] = todos(2)
assert.notEqual(todoKey(a, 0), todoKey(b, 1))
assert.notEqual(todoKey({ ...a }, 0), todoKey({ ...a }, 1))
assert.equal(typeof todoKey(null), 'string')
})
test('safeUrl: nur http(s)', () => {
assert.equal(safeUrl('https://invoice.stripe.com/i/acct_1/test_x'), 'https://invoice.stripe.com/i/acct_1/test_x')
assert.equal(safeUrl(' http://example.test/a '), 'http://example.test/a')
for (const bad of ['javascript:alert(1)', 'data:text/html,x', 'ftp://x.test', 'invoice.stripe.com/i', '', ' ', null, undefined, 5]) {
assert.equal(safeUrl(bad), '', String(bad))
}
})
test('todoButtonLabel: ein Knopf je Aktion', () => {
const label = (aktion) => todoButtonLabel({ aktion })
assert.equal(label({ typ: 'bank-verbinden' }), 'Bank verbinden')
assert.equal(label({ typ: 'bank-aktualisieren' }), 'Aktualisieren')
assert.equal(label({ typ: 'rechnung-oeffnen', hostedUrl: 'https://x.test/i' }), 'Zahlungsseite öffnen')
assert.equal(label({ typ: 'rechnung-oeffnen', hostedUrl: null }), 'Zu den Rechnungen')
assert.equal(label({ typ: 'rechnung-oeffnen', hostedUrl: 'javascript:alert(1)' }), 'Zu den Rechnungen')
assert.equal(label({ typ: 'zuordnen' }), 'Zuordnen')
assert.equal(label({ typ: 'kunde-oeffnen' }), 'Kunde öffnen')
assert.equal(label({ typ: 'fixkosten-uebernehmen' }), 'Übernehmen')
assert.equal(label({ typ: 'einstellungen' }), 'Einstellungen')
assert.equal(label({ typ: 'neu' }), '')
assert.equal(todoButtonLabel({}), '')
assert.equal(todoButtonLabel(null), '')
})
const suggestion = (extra = {}) => ({ key: 'hetzner', name: 'Hetzner', amountCents: 449, interval: 'monat', lastDate: '2026-09-27', count: 4, ...extra })
test('fixedCostFromSuggestion: Anfrage mit nächstem Termin und Rückfrage-Text', () => {
assert.deepEqual(fixedCostFromSuggestion(suggestion()), {
payload: { name: 'Hetzner', amountCents: 449, interval: 'monat', nextDate: '2026-10-27', counterpartyMatch: 'hetzner' },
confirmText: 'Als Fixkosten übernehmen: Hetzner, 4,49 € monatlich?',
})
const q = fixedCostFromSuggestion(suggestion({ interval: 'quartal', lastDate: '2026-11-30', amountCents: 123456 }))
assert.equal(q.payload.nextDate, '2027-02-28')
assert.equal(q.confirmText, 'Als Fixkosten übernehmen: Hetzner, 1.234,56 € vierteljährlich?')
assert.equal(fixedCostFromSuggestion(suggestion({ interval: 'jahr' })).confirmText, 'Als Fixkosten übernehmen: Hetzner, 4,49 € jährlich?')
})
test('fixedCostFromSuggestion: unvollständig ergibt null', () => {
assert.equal(fixedCostFromSuggestion(null), null)
assert.equal(fixedCostFromSuggestion({}), null)
assert.equal(fixedCostFromSuggestion(suggestion({ name: ' ' })), null)
assert.equal(fixedCostFromSuggestion(suggestion({ amountCents: 0 })), null)
assert.equal(fixedCostFromSuggestion(suggestion({ amountCents: 4.5 })), null)
assert.equal(fixedCostFromSuggestion(suggestion({ interval: 'woche' })), null)
assert.equal(fixedCostFromSuggestion(suggestion({ lastDate: 'Mai' })), null)
assert.equal(fixedCostFromSuggestion(suggestion({ key: undefined })).payload.counterpartyMatch, '')
})
test('planTodoAction: jede Aktionsart des Portals', () => {
assert.deepEqual(planTodoAction({ typ: 'bank-verbinden' }), { kind: 'bank-connect' })
assert.deepEqual(planTodoAction({ typ: 'bank-aktualisieren' }), { kind: 'bank-refresh' })
assert.deepEqual(planTodoAction({ typ: 'rechnung-oeffnen', invoiceId: 'in_1', hostedUrl: 'https://x.test/i' }), { kind: 'open-url', url: 'https://x.test/i' })
assert.deepEqual(planTodoAction({ typ: 'rechnung-oeffnen', invoiceId: 'in_1', hostedUrl: null }), { kind: 'scroll', id: 'finanzen-rechnungen' })
assert.equal(INVOICES_ANCHOR, 'finanzen-rechnungen')
assert.deepEqual(planTodoAction({ typ: 'rechnung-oeffnen', hostedUrl: 'javascript:alert(1)' }), { kind: 'scroll', id: 'finanzen-rechnungen' })
assert.deepEqual(planTodoAction({ typ: 'zuordnen', transactionId: 'b1' }), { kind: 'assign', transactionId: 'b1' })
assert.deepEqual(planTodoAction({ typ: 'kunde-oeffnen', customerId: 'c2' }), { kind: 'navigate', to: '/customers/c2' })
assert.deepEqual(planTodoAction({ typ: 'kunde-oeffnen', customerId: 'a/b c' }), { kind: 'navigate', to: '/customers/a%2Fb%20c' })
assert.deepEqual(planTodoAction({ typ: 'einstellungen' }), { kind: 'navigate', to: '/finance/einstellungen' })
const fixed = planTodoAction({ typ: 'fixkosten-uebernehmen', vorschlag: suggestion() })
assert.equal(fixed.kind, 'fixed-cost')
assert.equal(fixed.payload.nextDate, '2026-10-27')
assert.equal(fixed.confirmText, 'Als Fixkosten übernehmen: Hetzner, 4,49 € monatlich?')
})
test('planTodoAction: fehlende Angaben und unbekannte Aktionen geben eine Meldung', () => {
for (const aktion of [
{ typ: 'zuordnen' },
{ typ: 'zuordnen', transactionId: '' },
{ typ: 'kunde-oeffnen' },
{ typ: 'kunde-oeffnen', customerId: ' ' },
{ typ: 'fixkosten-uebernehmen' },
{ typ: 'fixkosten-uebernehmen', vorschlag: suggestion({ amountCents: -1 }) },
{ typ: 'unbekannt' },
null,
undefined,
]) {
const plan = planTodoAction(aktion)
assert.equal(plan.kind, 'error', JSON.stringify(aktion))
assert.ok(plan.message.length > 0)
}
})