Files
tickte-system/tests/unit/financeView.test.mjs
T

610 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
forecastRows,
forecastNotes,
taxView,
aboView,
productLabel,
aboStatus,
} = 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)
}
})
// --- 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('forecastRows: gleicher Tag, Abfluss zuerst wie vom Server geliefert, der Tiefpunkt bleibt sichtbar', () => {
// Server: am selben Tag Abflüsse zuerst (forecast.js), dann die Zuflüsse in Eingangsreihenfolge
const sameDay = [
{ date: '2026-10-05', label: 'Miete Büro', amountCents: -1500, sicherheit: 'sicher' },
{ date: '2026-10-05', label: 'Rechnung WK-0007 · Kunde Drei', amountCents: 1000, sicherheit: 'erwartet' },
{ date: '2026-10-05', label: 'Stripe-Auszahlung', amountCents: 300, sicherheit: 'sicher' },
{ date: '2026-10-06', label: 'Hetzner', amountCents: -100, sicherheit: 'sicher' },
]
const { rows } = forecastRows(sameDay, 1000)
// sicher: 1000 -1500 = -500, unverändert -500 (erwartet zählt nicht), +300 = -200, -100 = -300
assert.deepEqual(rows.map((r) => r.saldoSicherCents), [-500, -500, -200, -300])
// erwartet: 1000 -1500 = -500, +1000 = 500, +300 = 800, -100 = 700
assert.deepEqual(rows.map((r) => r.saldoErwartetCents), [-500, 500, 800, 700])
assert.deepEqual(rows.map((r) => r.saldoSicherNegative), [true, true, true, true])
assert.deepEqual(rows.map((r) => r.saldoErwartetNegative), [true, false, false, false])
assert.deepEqual(rows.map((r) => r.saldoSicherText), [`${MINUS}5,00 €`, `${MINUS}5,00 €`, `${MINUS}2,00 €`, `${MINUS}3,00 €`])
assert.deepEqual(rows.map((r) => r.saldoErwartetText), [`${MINUS}5,00 €`, '5,00 €', '8,00 €', '7,00 €'])
// der Tiefpunkt der Linie steht in der ersten Zeile des Tages (wie minSicher/minErwartet im Server: -500 am 05.10.)
assert.equal(Math.min(...rows.map((r) => r.saldoSicherCents)), -500)
assert.equal(rows.findIndex((r) => r.saldoSicherCents === -500), 0)
assert.equal(Math.min(...rows.map((r) => r.saldoErwartetCents)), -500)
// Zuflüsse zuerst würden den Tiefpunkt verstecken: 1000 +300 = 1300, -1500 = -200 statt -500
const inflowFirst = forecastRows([sameDay[2], sameDay[0]], 1000).rows
assert.equal(Math.min(...inflowFirst.map((r) => r.saldoSicherCents)), -200)
})
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 „<Betrag> 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 25.000-€-Grenze')
assert.equal(v.small.limitText, 'Über 25.000 € im Jahr 2026: ab 2027 keine Kleinunternehmerregelung.')
assert.equal(
v.small.hardText,
'Grenze 100.000 €: Ab dem Umsatz, der diese Grenze übersteigt, gilt schon im laufenden Jahr die Regelbesteuerung (Umsatzsteuer).'
)
assert.equal(v.small.previousText, null)
assert.equal(taxView(steuer({}, { anteilFolgejahr: 0.704 })).small.shareText, '70,4 % der 25.000-€-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)
// die Erläuterungen zu den Grenzen bleiben auch ohne Zähler
assert.equal(v.small.limitText, 'Über 25.000 € im Jahr 2026: ab 2027 keine Kleinunternehmerregelung.')
assert.equal(
v.small.hardText,
'Grenze 100.000 €: Ab dem Umsatz, der diese Grenze übersteigt, gilt schon im laufenden Jahr die Regelbesteuerung (Umsatzsteuer).'
)
// 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 25.000 €: im laufenden Jahr keine Kleinunternehmerregelung)'
)
})
test('taxView: Grenzen kommen aus dem Server (Rückfall nur ohne Wert), Jahr fehlt -> Folgejahr', () => {
const custom = taxView(steuer({ jahr: 2030 }, { grenzeFolgejahrCents: 3000000, grenzeHartCents: 12000000, anteilFolgejahr: 0.5, vorjahrCents: 3000001 }))
assert.equal(custom.small.counterText, '80,00 € von 30.000 € (2030)')
assert.equal(custom.small.shareText, '50 % der 30.000-€-Grenze')
assert.equal(custom.small.limitText, 'Über 30.000 € im Jahr 2030: ab 2031 keine Kleinunternehmerregelung.')
assert.match(custom.small.hardText, /^Grenze 120\.000 €: Ab dem Umsatz, der diese Grenze übersteigt, gilt schon im laufenden Jahr die Regelbesteuerung \(Umsatzsteuer\)\.$/)
assert.equal(custom.small.previousText, 'Vorjahr: 30.000,01 € (über 30.000 €: im laufenden Jahr keine Kleinunternehmerregelung)')
const noYear = taxView({ kleinunternehmer: { jahrCents: 100 } })
assert.equal(noYear.small.counterText, '1,00 € von 25.000 €')
assert.equal(noYear.small.limitText, 'Über 25.000 € im Jahr: ab dem Folgejahr keine Kleinunternehmerregelung.')
})
test('taxView: Gewerbesteuer-Hinweis nur mit gewerbesteuerHinweis', () => {
assert.equal(taxView(steuer()).gewerbeText, null)
assert.equal(taxView(steuer({ gewerbesteuerHinweis: false })).gewerbeText, null)
assert.equal(
taxView(steuer({ gewerbesteuerHinweis: true })).gewerbeText,
'Der Gewinn 2026 liegt über dem Gewerbesteuer-Freibetrag von 24.500 € (natürliche Personen und Personengesellschaften, § 11 GewStG). Auf den übersteigenden Gewerbeertrag kann Gewerbesteuer anfallen.'
)
// ohne Jahr entfällt die Jahreszahl
assert.equal(
taxView({ gewerbesteuerHinweis: true }).gewerbeText,
'Der Gewinn liegt über dem Gewerbesteuer-Freibetrag von 24.500 € (natürliche Personen und Personengesellschaften, § 11 GewStG). Auf den übersteigenden Gewerbeertrag kann Gewerbesteuer anfallen.'
)
})
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 €: Ab dem Umsatz, der diese Grenze übersteigt, gilt schon im laufenden Jahr die Regelbesteuerung (Umsatzsteuer).'
)
})
// --- 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' })
})