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 „ 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' }) })