diff --git a/src/components/finance/ForecastTable.jsx b/src/components/finance/ForecastTable.jsx index 4effc44..9346d57 100644 --- a/src/components/finance/ForecastTable.jsx +++ b/src/components/finance/ForecastTable.jsx @@ -5,8 +5,8 @@ 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. + * Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Karte bis 560 px) + * wird jede Zeile dreizeilig: Datum, Sicherheit und Betrag / Posten / beide Salden; ohne Salden sind es zwei Zeilen. */ export default function ForecastTable({ vorschau, kontostandCents, stripeDown = false, abosFehler = false }) { const v = vorschau && typeof vorschau === 'object' ? vorschau : {} diff --git a/src/components/finance/TaxCard.jsx b/src/components/finance/TaxCard.jsx index e512749..d997767 100644 --- a/src/components/finance/TaxCard.jsx +++ b/src/components/finance/TaxCard.jsx @@ -23,6 +23,7 @@ export default function TaxCard({ steuer }) {
Kleinunternehmer (§ 19 UStG)
{v.small.counterText}
{v.small.shareText &&
{v.small.shareText}
} +
{v.small.limitText}
{v.small.hardText}
{v.small.previousText &&
{v.small.previousText}
} diff --git a/src/lib/financeView.js b/src/lib/financeView.js index c3b5805..ece33fb 100644 --- a/src/lib/financeView.js +++ b/src/lib/financeView.js @@ -289,7 +289,7 @@ const GEWERBE_ALLOWANCE_CENTS = 2_450_000 /** * Texte der Steuerkarte aus `steuer`: - * { reserve: { amountText, basisText }, small: { known, counterText, shareText, hardText, previousText }, + * { reserve: { amountText, basisText }, small: { known, counterText, shareText, limitText, hardText, previousText }, * gewerbeText, prepayment: { dateText, amountText } | null }. * Ist der Kleinunternehmer-Zähler null (Stripe nicht erreichbar), steht „unbekannt“ statt einer Zahl. */ @@ -308,7 +308,9 @@ export function taxView(steuer) { } const previous = isNum(k.vorjahrCents) - ? `Vorjahr: ${euro(k.vorjahrCents)}${k.vorjahrCents > limit ? ` (über der Grenze von ${euroWhole(limit)})` : ''}` + ? `Vorjahr: ${euro(k.vorjahrCents)}${ + k.vorjahrCents > limit ? ` (über ${euroWhole(limit)}: im laufenden Jahr keine Kleinunternehmerregelung)` : '' + }` : null const prepay = s.naechsteVorauszahlung @@ -322,13 +324,19 @@ export function taxView(steuer) { 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.`, + shareText: + known && isNum(k.anteilFolgejahr) + ? `${percentDe(k.anteilFolgejahr * 100, 1)} % der ${euroWhole(limit).replace(' €', '-€')}-Grenze` + : null, + limitText: `Über ${euroWhole(limit)} im Jahr${year !== null ? ` ${year}` : ''}: ab ${ + year !== null ? year + 1 : 'dem Folgejahr' + } keine Kleinunternehmerregelung.`, + hardText: `Grenze ${euroWhole(hard)}: Ab dem Umsatz, der diese Grenze übersteigt, gilt schon im laufenden Jahr die Regelbesteuerung (Umsatzsteuer).`, previousText: previous, }, gewerbeText: s.gewerbesteuerHinweis === true - ? `Der Gewinn liegt über dem Gewerbesteuer-Freibetrag für Personengesellschaften (${euroWhole(GEWERBE_ALLOWANCE_CENTS)}). Es kann Gewerbesteuer anfallen.` + ? `Der Gewinn${year !== null ? ` ${year}` : ''} liegt über dem Gewerbesteuer-Freibetrag von ${euroWhole(GEWERBE_ALLOWANCE_CENTS)} (natürliche Personen und Personengesellschaften, § 11 GewStG). Auf den übersteigenden Gewerbeertrag kann Gewerbesteuer anfallen.` : null, prepayment, } diff --git a/tests/unit/financeView.test.mjs b/tests/unit/financeView.test.mjs index b85637a..68d4c99 100644 --- a/tests/unit/financeView.test.mjs +++ b/tests/unit/financeView.test.mjs @@ -342,6 +342,32 @@ test('forecastRows: Serverreihenfolge bleibt, Schlüssel sind eindeutig', () => 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 }) @@ -395,10 +421,14 @@ test('taxView: Kleinunternehmer-Zähler „ von 25.000 € (2026)“ und 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.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 Grenze') + 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)') }) @@ -407,7 +437,12 @@ test('taxView: Stripe nicht erreichbar -> unbekannt statt 0', () => { 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.') + // 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 €') }) @@ -418,15 +453,34 @@ test('taxView: Vorjahr nur wenn gesetzt, über der Grenze mit Hinweis', () => { 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 €)' + '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) - const t = taxView(steuer({ gewerbesteuerHinweis: true })).gewerbeText - assert.match(t, /Gewerbesteuer/) - assert.match(t, /24\.500 €/) + 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', () => { @@ -447,7 +501,10 @@ test('taxView: fehlende Teile werden nicht zu Müll', () => { // 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.') + 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) -----------------------------------------------------------------------------------------