Finanzen: Vorschau, Steuer und Abos

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 88a98b858f
commit 3826f1ab72
9 files changed
+780 -1

No files matched your search

+294
View File
@@ -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 „<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 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' })
})