Finanzen: Steuerruecklage, Kleinunternehmer-Zaehler und Aufgabenliste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
34dfe181e4
commit
7dbf2ce75f
4 files changed
+1039
No files matched your search
@@ -0,0 +1,174 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { computeTax } from '../server/services/finance/tax.js'
|
||||
|
||||
const TODAY = '2026-10-01'
|
||||
const tx = (extra = {}) => ({ $id: 't', status: 'BOOK', bookingDate: '2026-09-15', amountCents: -449, counterpartyName: 'Hetzner', remittance: '', category: 'fixkosten', ...extra })
|
||||
const settings = (extra = {}) => ({ taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, ...extra })
|
||||
const invoice = (extra = {}) => ({ id: 'in_1', number: 'WK-2026-0001', status: 'bezahlt', paidCents: 5000, paidDate: '2026-09-20', source: 'projekt', ...extra })
|
||||
const run = (extra = {}) => computeTax({ today: TODAY, transactions: [], invoices: [], settings: settings(), ...extra })
|
||||
|
||||
// Beispiel aus dem Plan: 168,79 EUR Eingang, 59,86 EUR Ausgaben, 100 EUR privat
|
||||
const planTransactions = () => [
|
||||
tx({ $id: 'in', amountCents: 16879, category: 'rechnung-extern', bookingDate: '2026-08-05' }),
|
||||
...[449, 449, 449, 50, 50, 50, 131, 4358].map((cents, i) => tx({ $id: `out${i}`, amountCents: -cents })),
|
||||
tx({ $id: 'privat', amountCents: -10000, category: 'privat' }),
|
||||
]
|
||||
|
||||
test('Plan-Beispiel: Einnahmen, Ausgaben, Gewinn, Rücklage und Kleinunternehmer-Zähler', () => {
|
||||
const r = run({ transactions: planTransactions() })
|
||||
assert.equal(r.jahr, 2026)
|
||||
assert.equal(r.einnahmenCents, 16879)
|
||||
assert.equal(r.ausgabenCents, 5986)
|
||||
assert.equal(r.gewinnCents, 10893)
|
||||
assert.equal(r.ruecklageCents, 3268)
|
||||
assert.equal(r.prozent, 30)
|
||||
assert.equal(r.kleinunternehmer.jahrCents, 16879)
|
||||
assert.equal(r.gewerbesteuerHinweis, false)
|
||||
assert.equal(r.naechsteVorauszahlung, null)
|
||||
})
|
||||
|
||||
test('Kleinunternehmer-Grenzen und Anteil am Folgejahr-Limit', () => {
|
||||
const r = run({ transactions: planTransactions() })
|
||||
assert.equal(r.kleinunternehmer.grenzeFolgejahrCents, 2500000)
|
||||
assert.equal(r.kleinunternehmer.grenzeHartCents, 10000000)
|
||||
assert.equal(r.kleinunternehmer.anteilFolgejahr, 16879 / 2500000)
|
||||
assert.equal(r.kleinunternehmer.vorjahrCents, null)
|
||||
})
|
||||
|
||||
test('Vorjahresumsatz kommt aus den Einstellungen, ungültige Werte werden zu null', () => {
|
||||
assert.equal(run({ settings: settings({ previousYearRevenueCents: 1234500 }) }).kleinunternehmer.vorjahrCents, 1234500)
|
||||
assert.equal(run({ settings: settings({ previousYearRevenueCents: 0 }) }).kleinunternehmer.vorjahrCents, 0)
|
||||
assert.equal(run({ settings: settings({ previousYearRevenueCents: 'viel' }) }).kleinunternehmer.vorjahrCents, null)
|
||||
})
|
||||
|
||||
test('negativer Gewinn: Rücklage 0, Gewinn bleibt negativ', () => {
|
||||
const r = run({ transactions: [tx({ amountCents: 1000, category: 'rechnung-extern' }), tx({ amountCents: -5000 })] })
|
||||
assert.equal(r.gewinnCents, -4000)
|
||||
assert.equal(r.ruecklageCents, 0)
|
||||
})
|
||||
|
||||
test('Rücklage wird kaufmännisch auf ganze Cent gerundet und folgt dem Prozentsatz der Einstellungen', () => {
|
||||
const rows = [tx({ amountCents: 10893, category: 'rechnung-extern' })]
|
||||
assert.equal(run({ transactions: rows, settings: settings({ taxReservePercent: 30 }) }).ruecklageCents, 3268)
|
||||
assert.equal(run({ transactions: rows, settings: settings({ taxReservePercent: 25 }) }).ruecklageCents, 2723) // 2723,25
|
||||
assert.equal(run({ transactions: rows, settings: settings({ taxReservePercent: 0 }) }).ruecklageCents, 0)
|
||||
assert.equal(run({ transactions: rows, settings: settings({ taxReservePercent: 25 }) }).prozent, 25)
|
||||
})
|
||||
|
||||
test('ohne Einstellungen gelten 30 Prozent', () => {
|
||||
const rows = [tx({ amountCents: 10000, category: 'rechnung-extern' })]
|
||||
const r = computeTax({ today: TODAY, transactions: rows, invoices: [], settings: null })
|
||||
assert.equal(r.prozent, 30)
|
||||
assert.equal(r.ruecklageCents, 3000)
|
||||
assert.equal(r.kleinunternehmer.vorjahrCents, null)
|
||||
})
|
||||
|
||||
test('nur gebuchte Buchungen des laufenden Kalenderjahres zählen', () => {
|
||||
const r = run({
|
||||
transactions: [
|
||||
tx({ amountCents: 1000, category: 'rechnung-extern', bookingDate: '2026-01-01' }),
|
||||
tx({ amountCents: 2000, category: 'rechnung-extern', bookingDate: '2025-12-31' }),
|
||||
tx({ amountCents: 4000, category: 'rechnung-extern', bookingDate: '2027-01-01' }),
|
||||
tx({ amountCents: 8000, category: 'rechnung-extern', status: 'PDNG' }),
|
||||
tx({ amountCents: -300, bookingDate: '2025-12-31' }),
|
||||
tx({ amountCents: -70, status: 'PDNG' }),
|
||||
tx({ amountCents: -20, bookingDate: '2026-12-31' }),
|
||||
],
|
||||
})
|
||||
assert.equal(r.einnahmenCents, 1000)
|
||||
assert.equal(r.ausgabenCents, 20)
|
||||
assert.equal(r.kleinunternehmer.jahrCents, 1000)
|
||||
})
|
||||
|
||||
test('Privat zählt weder bei Einnahmen noch bei Ausgaben, Rechnung/Auszahlung zählen als Einnahmen', () => {
|
||||
const r = run({
|
||||
transactions: [
|
||||
tx({ amountCents: 700, category: 'privat' }),
|
||||
tx({ amountCents: -900, category: 'privat' }),
|
||||
tx({ amountCents: 5000, category: 'rechnung' }),
|
||||
tx({ amountCents: 3000, category: 'stripe-auszahlung' }),
|
||||
tx({ amountCents: 1000, category: '' }),
|
||||
],
|
||||
})
|
||||
assert.equal(r.einnahmenCents, 9000)
|
||||
assert.equal(r.ausgabenCents, 0)
|
||||
})
|
||||
|
||||
test('Kleinunternehmer: Stripe-Rechnungen mit Zahltag im Jahr zählen dazu, Auszahlungen und Rechnungsbuchungen nicht', () => {
|
||||
const base = run({ transactions: planTransactions() })
|
||||
const r = run({
|
||||
transactions: [
|
||||
...planTransactions(),
|
||||
tx({ amountCents: 9999, category: 'stripe-auszahlung' }), // sonst doppelt
|
||||
tx({ amountCents: 7777, category: 'rechnung' }), // gehört zu einer Stripe-Rechnung
|
||||
],
|
||||
invoices: [
|
||||
invoice({ paidCents: 5000, paidDate: '2026-10-02' }),
|
||||
invoice({ id: 'in_2', paidCents: 300, paidDate: '2026-01-01' }),
|
||||
invoice({ id: 'in_3', paidCents: 4000, paidDate: '2025-12-31' }),
|
||||
invoice({ id: 'in_4', paidCents: 6000, paidDate: '2027-01-01' }),
|
||||
invoice({ id: 'in_5', paidCents: 0, paidDate: null, status: 'offen' }),
|
||||
],
|
||||
})
|
||||
assert.equal(base.kleinunternehmer.jahrCents, 16879)
|
||||
assert.equal(r.kleinunternehmer.jahrCents, 16879 + 5000 + 300)
|
||||
assert.equal(r.kleinunternehmer.anteilFolgejahr, (16879 + 5300) / 2500000)
|
||||
})
|
||||
|
||||
test('Gewerbesteuer-Hinweis erst bei Gewinn über 24.500 Euro', () => {
|
||||
const gain = (cents) => run({ transactions: [tx({ amountCents: cents, category: 'rechnung-extern' })] })
|
||||
assert.equal(gain(2450000).gewerbesteuerHinweis, false)
|
||||
assert.equal(gain(2450001).gewerbesteuerHinweis, true)
|
||||
// Ausgaben mindern den Gewinn
|
||||
const r = run({ transactions: [tx({ amountCents: 2500000, category: 'rechnung-extern' }), tx({ amountCents: -100000 })] })
|
||||
assert.equal(r.gewinnCents, 2400000)
|
||||
assert.equal(r.gewerbesteuerHinweis, false)
|
||||
})
|
||||
|
||||
test('nächste ESt-Vorauszahlung: nächster 10.03./10.06./10.09./10.12., heute zählt mit', () => {
|
||||
const next = (today, cents = 50000) => computeTax({ today, transactions: [], invoices: [], settings: settings({ estPrepaymentCents: cents }) }).naechsteVorauszahlung
|
||||
assert.deepEqual(next('2026-10-01'), { date: '2026-12-10', amountCents: 50000 })
|
||||
assert.deepEqual(next('2026-01-15').date, '2026-03-10')
|
||||
assert.deepEqual(next('2026-03-10').date, '2026-03-10')
|
||||
assert.deepEqual(next('2026-03-11').date, '2026-06-10')
|
||||
assert.deepEqual(next('2026-06-11').date, '2026-09-10')
|
||||
assert.deepEqual(next('2026-09-11').date, '2026-12-10')
|
||||
assert.deepEqual(next('2026-12-10').date, '2026-12-10')
|
||||
assert.deepEqual(next('2026-12-11'), { date: '2027-03-10', amountCents: 50000 })
|
||||
})
|
||||
|
||||
test('keine ESt-Vorauszahlung eingestellt: naechsteVorauszahlung ist null', () => {
|
||||
assert.equal(run({ settings: settings({ estPrepaymentCents: 0 }) }).naechsteVorauszahlung, null)
|
||||
assert.equal(run({ settings: settings({ estPrepaymentCents: null }) }).naechsteVorauszahlung, null)
|
||||
assert.equal(run({ settings: null }).naechsteVorauszahlung, null)
|
||||
})
|
||||
|
||||
test('Jahr folgt today (Jahreswechsel) und Berichtsjahr wird mitgegeben', () => {
|
||||
const rows = [tx({ amountCents: 1000, category: 'rechnung-extern', bookingDate: '2026-12-31' }), tx({ amountCents: 2000, category: 'rechnung-extern', bookingDate: '2027-01-02' })]
|
||||
const r = computeTax({ today: '2027-01-05', transactions: rows, invoices: [], settings: settings() })
|
||||
assert.equal(r.jahr, 2027)
|
||||
assert.equal(r.einnahmenCents, 2000)
|
||||
})
|
||||
|
||||
test('leere und fehlende Listen, kaputte Datensätze: keine Ausnahme', () => {
|
||||
const empty = computeTax({ today: TODAY, settings: settings() })
|
||||
assert.equal(empty.einnahmenCents, 0)
|
||||
assert.equal(empty.gewinnCents, 0)
|
||||
assert.equal(empty.ruecklageCents, 0)
|
||||
assert.equal(empty.kleinunternehmer.jahrCents, 0)
|
||||
const r = run({
|
||||
transactions: [tx({ amountCents: 1.5 }), tx({ amountCents: 'x' }), tx({ bookingDate: undefined }), tx({ amountCents: 1000, category: 'rechnung-extern' }), null],
|
||||
invoices: [invoice({ paidCents: 1.5 }), invoice({ paidDate: 'gestern' }), invoice({ paidCents: -500 }), null],
|
||||
})
|
||||
assert.equal(r.einnahmenCents, 1000)
|
||||
assert.equal(r.kleinunternehmer.jahrCents, 1000)
|
||||
})
|
||||
|
||||
test('Eingaben werden nicht verändert', () => {
|
||||
const transactions = planTransactions()
|
||||
const invoices = [invoice()]
|
||||
const copy = JSON.stringify({ transactions, invoices })
|
||||
run({ transactions, invoices })
|
||||
assert.equal(JSON.stringify({ transactions, invoices }), copy)
|
||||
})
|
||||
@@ -0,0 +1,447 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildTodos } from '../server/services/finance/todos.js'
|
||||
import { addDays } from '../server/services/finance/dates.js'
|
||||
import { euroDe } from '../server/services/finance/format.js'
|
||||
|
||||
const TODAY = '2026-10-01'
|
||||
const NOW = new Date('2026-10-01T10:00:00.000Z')
|
||||
const HOUR = 3_600_000
|
||||
const ago = (hours) => new Date(NOW.getTime() - hours * HOUR).toISOString()
|
||||
|
||||
const connection = (extra = {}) => ({ status: 'aktiv', validUntil: '2027-03-29T00:00:00.000Z', lastSyncAt: ago(1), lastSyncOk: true, lastError: '', ...extra })
|
||||
const invoice = (extra = {}) => ({
|
||||
id: 'in_1', number: 'WK-2026-0001', status: 'ueberfaellig', customerName: 'Muster GmbH', createdDate: '2026-08-01', dueDate: addDays(TODAY, -5),
|
||||
remainingCents: 50000, paidCents: 0, paidDate: null, overdueDays: 5, source: 'projekt', hostedUrl: 'https://pay.example/in_1', ...extra,
|
||||
})
|
||||
const inflow = (extra = {}) => ({
|
||||
$id: 'tx1', status: 'BOOK', bookingDate: '2026-09-28', amountCents: 16879, counterpartyName: 'EWV Software GmbH', remittance: 'Rechnung 0005', category: '', ...extra,
|
||||
})
|
||||
const sub = (extra = {}) => ({ customerId: 'c1', status: 'past_due', priceAmount: 2000, productKey: 'hosting', ...extra })
|
||||
const tax = (anteil = 0.1, extra = {}) => ({ jahr: 2026, kleinunternehmer: { jahrCents: Math.round(anteil * 2500000), grenzeFolgejahrCents: 2500000, grenzeHartCents: 10000000, anteilFolgejahr: anteil, ...extra } })
|
||||
const recurring = (extra = {}) => ({ key: 'hetzner', name: 'Hetzner', amountCents: 449, interval: 'monat', lastDate: '2026-09-27', count: 4, ...extra })
|
||||
|
||||
const todos = (extra = {}) => buildTodos({ today: TODAY, now: NOW, connection: connection(), invoices: [], transactions: [], subscriptions: [], recurring: [], tax: tax(), ...extra })
|
||||
const only = (list, art) => list.filter((t) => t.art === art)
|
||||
|
||||
test('alles ruhig: keine Aufgaben', () => {
|
||||
assert.deepEqual(todos(), [])
|
||||
})
|
||||
|
||||
test('Standardwerte: fehlende Listen und tax dürfen fehlen', () => {
|
||||
assert.deepEqual(buildTodos({ today: TODAY, now: NOW, connection: connection() }), [])
|
||||
assert.deepEqual(buildTodos({ today: TODAY, now: NOW, connection: connection(), invoices: null, transactions: null, subscriptions: null, recurring: null, tax: null }), [])
|
||||
})
|
||||
|
||||
// --- bank-zugang ---------------------------------------------------------------------------------------------
|
||||
|
||||
test('bank-zugang: keine Verbindung -> „Bankkonto verbinden“ hoch', () => {
|
||||
for (const noConnection of [null, undefined]) {
|
||||
const list = todos({ connection: noConnection })
|
||||
assert.equal(list.length, 1)
|
||||
assert.deepEqual(list[0], {
|
||||
art: 'bank-zugang',
|
||||
dringlichkeit: 'hoch',
|
||||
titel: 'Bankkonto verbinden',
|
||||
text: list[0].text,
|
||||
datum: TODAY,
|
||||
aktion: { typ: 'bank-verbinden' },
|
||||
})
|
||||
assert.match(list[0].text, /Konto/)
|
||||
}
|
||||
})
|
||||
|
||||
test('bank-zugang: Zugang gültig bis 20.10. (19 Tage) -> mittel, bis 10.10. (9 Tage) -> hoch', () => {
|
||||
const mid = only(todos({ connection: connection({ validUntil: '2026-10-20T00:00:00.000Z' }) }), 'bank-zugang')
|
||||
assert.equal(mid.length, 1)
|
||||
assert.equal(mid[0].dringlichkeit, 'mittel')
|
||||
assert.equal(mid[0].titel, 'Bankzugang läuft ab')
|
||||
assert.equal(mid[0].datum, '2026-10-20')
|
||||
assert.deepEqual(mid[0].aktion, { typ: 'bank-verbinden' })
|
||||
assert.match(mid[0].text, /20\.10\.2026/)
|
||||
assert.match(mid[0].text, /19 Tagen/)
|
||||
const high = only(todos({ connection: connection({ validUntil: '2026-10-10T00:00:00.000Z' }) }), 'bank-zugang')
|
||||
assert.equal(high.length, 1)
|
||||
assert.equal(high[0].dringlichkeit, 'hoch')
|
||||
})
|
||||
|
||||
test('bank-zugang: Grenzen 14/15 und 30/31 Tage, Berliner Kalendertag', () => {
|
||||
const level = (validUntil) => only(todos({ connection: connection({ validUntil }) }), 'bank-zugang')[0]?.dringlichkeit ?? null
|
||||
assert.equal(level('2026-10-15T12:00:00.000Z'), 'hoch') // 14 Tage
|
||||
assert.equal(level('2026-10-16T12:00:00.000Z'), 'mittel') // 15 Tage
|
||||
assert.equal(level('2026-10-31T12:00:00.000Z'), 'mittel') // 30 Tage
|
||||
assert.equal(level('2026-11-01T12:00:00.000Z'), null) // 31 Tage
|
||||
// 23:30 UTC ist in Berlin schon der nächste Tag: 15.10. 23:30 UTC = 16.10. -> 15 Tage
|
||||
assert.equal(level('2026-10-15T23:30:00.000Z'), 'mittel')
|
||||
assert.equal(level('2026-10-01T12:00:00.000Z'), 'hoch') // läuft heute ab
|
||||
})
|
||||
|
||||
test('bank-zugang: Status abgelaufen -> „Bankzugang abgelaufen“ hoch, egal was validUntil sagt', () => {
|
||||
for (const validUntil of ['2027-03-29T00:00:00.000Z', '2026-09-01T00:00:00.000Z', '', undefined]) {
|
||||
const list = only(todos({ connection: connection({ status: 'abgelaufen', validUntil }) }), 'bank-zugang')
|
||||
assert.equal(list.length, 1)
|
||||
assert.equal(list[0].titel, 'Bankzugang abgelaufen')
|
||||
assert.equal(list[0].dringlichkeit, 'hoch')
|
||||
assert.deepEqual(list[0].aktion, { typ: 'bank-verbinden' })
|
||||
}
|
||||
})
|
||||
|
||||
test('bank-zugang: Datum der abgelaufenen Aufgabe ist nie in der Zukunft', () => {
|
||||
const datum = (validUntil) => only(todos({ connection: connection({ status: 'abgelaufen', validUntil }) }), 'bank-zugang')[0].datum
|
||||
assert.equal(datum('2027-03-29T00:00:00.000Z'), TODAY)
|
||||
assert.equal(datum('2026-09-01T10:00:00.000Z'), '2026-09-01')
|
||||
assert.equal(datum(''), TODAY)
|
||||
})
|
||||
|
||||
test('bank-zugang: Text nennt heute, morgen oder die Tage', () => {
|
||||
const text = (validUntil) => only(todos({ connection: connection({ validUntil }) }), 'bank-zugang')[0].text
|
||||
assert.match(text('2026-10-01T12:00:00.000Z'), /\(heute\)/)
|
||||
assert.match(text('2026-10-02T12:00:00.000Z'), /\(morgen\)/)
|
||||
assert.match(text('2026-10-04T12:00:00.000Z'), /\(in 3 Tagen\)/)
|
||||
})
|
||||
|
||||
test('bank-zugang: Status aktiv, aber validUntil liegt schon zurück -> abgelaufen hoch', () => {
|
||||
const list = only(todos({ connection: connection({ validUntil: '2026-09-30T10:00:00.000Z' }) }), 'bank-zugang')
|
||||
assert.equal(list.length, 1)
|
||||
assert.equal(list[0].titel, 'Bankzugang abgelaufen')
|
||||
assert.equal(list[0].dringlichkeit, 'hoch')
|
||||
assert.match(list[0].text, /30\.09\.2026/)
|
||||
})
|
||||
|
||||
test('bank-zugang: ohne lesbares validUntil keine Aufgabe (aktiv), Status fehler folgt validUntil', () => {
|
||||
assert.deepEqual(only(todos({ connection: connection({ validUntil: '' }) }), 'bank-zugang'), [])
|
||||
assert.deepEqual(only(todos({ connection: connection({ validUntil: 'bald' }) }), 'bank-zugang'), [])
|
||||
assert.deepEqual(only(todos({ connection: connection({ status: 'fehler' }) }), 'bank-zugang'), [])
|
||||
assert.equal(only(todos({ connection: connection({ status: 'fehler', validUntil: '2026-10-05T00:00:00.000Z' }) }), 'bank-zugang')[0].dringlichkeit, 'hoch')
|
||||
})
|
||||
|
||||
// --- bank-abruf ----------------------------------------------------------------------------------------------
|
||||
|
||||
test('bank-abruf: letzter Abruf vor 25 h -> mittel, Aktion bank-aktualisieren', () => {
|
||||
const list = only(todos({ connection: connection({ lastSyncAt: ago(25) }) }), 'bank-abruf')
|
||||
assert.equal(list.length, 1)
|
||||
assert.equal(list[0].dringlichkeit, 'mittel')
|
||||
assert.equal(list[0].titel, 'Bankabruf veraltet')
|
||||
assert.equal(list[0].datum, '2026-09-30')
|
||||
assert.deepEqual(list[0].aktion, { typ: 'bank-aktualisieren' })
|
||||
assert.match(list[0].text, /30\.09\.2026/)
|
||||
})
|
||||
|
||||
test('bank-abruf: 23 h und genau 24 h sind noch frisch', () => {
|
||||
assert.deepEqual(only(todos({ connection: connection({ lastSyncAt: ago(23) }) }), 'bank-abruf'), [])
|
||||
assert.deepEqual(only(todos({ connection: connection({ lastSyncAt: ago(24) }) }), 'bank-abruf'), [])
|
||||
assert.equal(only(todos({ connection: connection({ lastSyncAt: new Date(NOW.getTime() - 24 * HOUR - 1).toISOString() }) }), 'bank-abruf').length, 1)
|
||||
})
|
||||
|
||||
test('bank-abruf: lastSyncOk false oder fehlender Abruf; Fehlertext erscheint', () => {
|
||||
const failed = only(todos({ connection: connection({ lastSyncOk: false, lastError: 'Die Bank antwortet nicht.' }) }), 'bank-abruf')
|
||||
assert.equal(failed.length, 1)
|
||||
assert.equal(failed[0].titel, 'Bankabruf fehlgeschlagen')
|
||||
assert.match(failed[0].text, /Die Bank antwortet nicht\./)
|
||||
assert.equal(failed[0].datum, TODAY) // letzter Abruf vor 1 h = heute
|
||||
const never = only(todos({ connection: connection({ lastSyncAt: '', lastSyncOk: true }) }), 'bank-abruf')
|
||||
assert.equal(never.length, 1)
|
||||
assert.equal(never[0].datum, TODAY)
|
||||
assert.equal(only(todos({ connection: connection({ lastSyncAt: undefined }) }), 'bank-abruf').length, 1)
|
||||
assert.equal(only(todos({ connection: connection({ lastSyncAt: 'neulich' }) }), 'bank-abruf').length, 1)
|
||||
})
|
||||
|
||||
test('bank-abruf: nur bei Status aktiv und mit Verbindung', () => {
|
||||
assert.deepEqual(only(todos({ connection: connection({ status: 'abgelaufen', lastSyncAt: ago(100), lastSyncOk: false }) }), 'bank-abruf'), [])
|
||||
assert.deepEqual(only(todos({ connection: connection({ status: 'fehler', lastSyncAt: ago(100) }) }), 'bank-abruf'), [])
|
||||
assert.deepEqual(only(todos({ connection: null }), 'bank-abruf'), [])
|
||||
})
|
||||
|
||||
// --- ueberfaellig --------------------------------------------------------------------------------------------
|
||||
|
||||
test('ueberfaellig: 31 Tage Verzug -> hoch, 5 Tage -> mittel', () => {
|
||||
const late = only(todos({ invoices: [invoice({ dueDate: addDays(TODAY, -31), overdueDays: 31 })] }), 'ueberfaellig')
|
||||
assert.equal(late.length, 1)
|
||||
assert.equal(late[0].dringlichkeit, 'hoch')
|
||||
const soon = only(todos({ invoices: [invoice({ dueDate: addDays(TODAY, -5) })] }), 'ueberfaellig')
|
||||
assert.equal(soon.length, 1)
|
||||
assert.equal(soon[0].dringlichkeit, 'mittel')
|
||||
})
|
||||
|
||||
test('ueberfaellig: Grenze bei 30 Tagen, Inhalt und Aktion', () => {
|
||||
const level = (days) => only(todos({ invoices: [invoice({ dueDate: addDays(TODAY, -days) })] }), 'ueberfaellig')[0].dringlichkeit
|
||||
assert.equal(level(29), 'mittel')
|
||||
assert.equal(level(30), 'hoch')
|
||||
assert.equal(level(1), 'mittel')
|
||||
const [t] = only(todos({ invoices: [invoice()] }), 'ueberfaellig')
|
||||
assert.equal(t.titel, 'Rechnung WK-2026-0001 überfällig')
|
||||
assert.equal(t.datum, '2026-09-26')
|
||||
assert.deepEqual(t.aktion, { typ: 'rechnung-oeffnen', invoiceId: 'in_1', hostedUrl: 'https://pay.example/in_1' })
|
||||
assert.ok(t.text.includes('Muster GmbH'))
|
||||
assert.ok(t.text.includes(euroDe(50000)))
|
||||
assert.ok(t.text.includes('26.09.2026'))
|
||||
assert.ok(t.text.includes('5 Tage'))
|
||||
assert.ok(only(todos({ invoices: [invoice({ dueDate: addDays(TODAY, -1) })] }), 'ueberfaellig')[0].text.includes('1 Tag'))
|
||||
})
|
||||
|
||||
test('ueberfaellig: offene Rechnung mit abgelaufener Fälligkeit zählt, nicht fällige, bezahlte und Entwürfe nicht', () => {
|
||||
assert.equal(only(todos({ invoices: [invoice({ status: 'offen', dueDate: '2026-09-30' })] }), 'ueberfaellig').length, 1)
|
||||
assert.deepEqual(only(todos({ invoices: [invoice({ status: 'offen', dueDate: TODAY })] }), 'ueberfaellig'), [])
|
||||
assert.deepEqual(only(todos({ invoices: [invoice({ status: 'offen', dueDate: '2026-10-14' })] }), 'ueberfaellig'), [])
|
||||
assert.deepEqual(only(todos({ invoices: [invoice({ status: 'bezahlt', paidCents: 50000, remainingCents: 0 })] }), 'ueberfaellig'), [])
|
||||
assert.deepEqual(only(todos({ invoices: [invoice({ status: 'entwurf' }), invoice({ id: 'in_2', status: 'storniert' }), invoice({ id: 'in_3', status: 'uneinbringlich' })] }), 'ueberfaellig'), [])
|
||||
})
|
||||
|
||||
test('ueberfaellig: ohne hostedUrl -> hostedUrl null', () => {
|
||||
const [t] = only(todos({ invoices: [invoice({ hostedUrl: undefined })] }), 'ueberfaellig')
|
||||
assert.deepEqual(t.aktion, { typ: 'rechnung-oeffnen', invoiceId: 'in_1', hostedUrl: null })
|
||||
})
|
||||
|
||||
// --- zuordnung -----------------------------------------------------------------------------------------------
|
||||
|
||||
test('zuordnung: ungeordneter Eingang -> mittel mit transactionId', () => {
|
||||
const list = only(todos({ transactions: [inflow()] }), 'zuordnung')
|
||||
assert.equal(list.length, 1)
|
||||
assert.equal(list[0].dringlichkeit, 'mittel')
|
||||
assert.equal(list[0].titel, 'Zahlungseingang zuordnen')
|
||||
assert.equal(list[0].datum, '2026-09-28')
|
||||
assert.deepEqual(list[0].aktion, { typ: 'zuordnen', transactionId: 'tx1' })
|
||||
assert.ok(list[0].text.includes(euroDe(16879)))
|
||||
assert.ok(list[0].text.includes('EWV Software GmbH'))
|
||||
assert.ok(list[0].text.includes('28.09.2026'))
|
||||
assert.ok(list[0].text.includes('Rechnung 0005'))
|
||||
assert.ok(!list[0].text.includes('Vorschlag'))
|
||||
})
|
||||
|
||||
test('zuordnung: nur gebuchte Eingänge ohne Kategorie', () => {
|
||||
const none = [
|
||||
inflow({ status: 'PDNG' }),
|
||||
inflow({ amountCents: -500 }),
|
||||
inflow({ amountCents: 0 }),
|
||||
inflow({ category: 'rechnung' }),
|
||||
inflow({ category: 'privat' }),
|
||||
inflow({ amountCents: 1.5 }),
|
||||
inflow({ bookingDate: 'heute' }),
|
||||
]
|
||||
assert.deepEqual(only(todos({ transactions: none }), 'zuordnung'), [])
|
||||
for (const category of [undefined, null, '']) assert.equal(only(todos({ transactions: [inflow({ category })] }), 'zuordnung').length, 1)
|
||||
})
|
||||
|
||||
test('zuordnung: Vorschlag hoch hängt „Vorschlag: Rechnung <Nr.>“ bzw. „Vorschlag: Stripe-Auszahlung“ an', () => {
|
||||
const invoiceHint = { kind: 'invoice', id: 'in_9', number: 'WK-2026-0009', confidence: 'hoch', reason: 'Betrag und Nummer', amountDiffers: false }
|
||||
const payoutHint = { kind: 'payout', id: 'po_1', number: '', confidence: 'hoch', reason: 'Betrag und Datum', amountDiffers: false }
|
||||
const [a] = only(todos({ transactions: [inflow({ vorschlaege: [invoiceHint] })] }), 'zuordnung')
|
||||
assert.ok(a.text.endsWith('Vorschlag: Rechnung WK-2026-0009'))
|
||||
const [b] = only(todos({ transactions: [inflow({ vorschlaege: [payoutHint] })] }), 'zuordnung')
|
||||
assert.ok(b.text.endsWith('Vorschlag: Stripe-Auszahlung'))
|
||||
assert.deepEqual(b.aktion, { typ: 'zuordnen', transactionId: 'tx1' })
|
||||
})
|
||||
|
||||
test('zuordnung: nur ein Vorschlag mit Sicherheit hoch zählt, der erste davon', () => {
|
||||
const hint = (extra) => ({ kind: 'invoice', id: 'in_x', number: 'WK-2026-0001', confidence: 'mittel', reason: '', amountDiffers: false, ...extra })
|
||||
const mid = only(todos({ transactions: [inflow({ vorschlaege: [hint({}), hint({ confidence: 'niedrig' })] })] }), 'zuordnung')[0]
|
||||
assert.ok(!mid.text.includes('Vorschlag'))
|
||||
const mixed = only(todos({ transactions: [inflow({ vorschlaege: [hint({}), hint({ confidence: 'hoch', number: 'WK-2026-0002' }), hint({ confidence: 'hoch', number: 'WK-2026-0003' })] })] }), 'zuordnung')[0]
|
||||
assert.ok(mixed.text.endsWith('Vorschlag: Rechnung WK-2026-0002'))
|
||||
assert.ok(!only(todos({ transactions: [inflow({ vorschlaege: [] })] }), 'zuordnung')[0].text.includes('Vorschlag'))
|
||||
})
|
||||
|
||||
// --- abo-zahlung ---------------------------------------------------------------------------------------------
|
||||
|
||||
test('abo-zahlung: past_due und unpaid -> hoch, Aktion kunde-oeffnen', () => {
|
||||
const list = only(todos({ subscriptions: [sub(), sub({ customerId: 'c2', status: 'unpaid', priceAmount: 5000 })] }), 'abo-zahlung')
|
||||
assert.equal(list.length, 2)
|
||||
for (const t of list) assert.equal(t.dringlichkeit, 'hoch')
|
||||
assert.deepEqual(list.map((t) => t.aktion), [
|
||||
{ typ: 'kunde-oeffnen', customerId: 'c1' },
|
||||
{ typ: 'kunde-oeffnen', customerId: 'c2' },
|
||||
])
|
||||
assert.equal(list[0].titel, 'Abo-Zahlung fehlgeschlagen')
|
||||
assert.equal(list[1].titel, 'Abo unbezahlt')
|
||||
assert.equal(list[0].datum, TODAY)
|
||||
assert.ok(list[0].text.includes('hosting'))
|
||||
assert.ok(list[0].text.includes(euroDe(2000)))
|
||||
})
|
||||
|
||||
test('abo-zahlung: aktive, gekündigte und unbekannte Abos erzeugen nichts; Kundenname und planKey werden genutzt', () => {
|
||||
assert.deepEqual(only(todos({ subscriptions: [sub({ status: 'active' }), sub({ status: 'canceled' }), sub({ status: 'trialing' })] }), 'abo-zahlung'), [])
|
||||
const [t] = only(todos({ subscriptions: [sub({ productKey: undefined, planKey: 'wartung', customerName: 'Muster GmbH' })] }), 'abo-zahlung')
|
||||
assert.ok(t.text.includes('Muster GmbH'))
|
||||
assert.ok(t.text.includes('wartung'))
|
||||
const [bare] = only(todos({ subscriptions: [sub({ productKey: undefined, priceAmount: undefined })] }), 'abo-zahlung')
|
||||
assert.equal(bare.art, 'abo-zahlung')
|
||||
})
|
||||
|
||||
// --- entwurf -------------------------------------------------------------------------------------------------
|
||||
|
||||
test('entwurf: vor 4 Tagen -> info, von gestern -> keine Aufgabe, auch 3 Tage noch nicht', () => {
|
||||
const draft = (days) => invoice({ id: 'in_d', number: '', status: 'entwurf', createdDate: addDays(TODAY, -days), remainingCents: 24000, hostedUrl: null })
|
||||
const [t] = only(todos({ invoices: [draft(4)] }), 'entwurf')
|
||||
assert.equal(t.dringlichkeit, 'info')
|
||||
assert.equal(t.titel, 'Rechnungsentwurf nicht versendet')
|
||||
assert.equal(t.datum, '2026-09-27')
|
||||
assert.deepEqual(t.aktion, { typ: 'rechnung-oeffnen', invoiceId: 'in_d', hostedUrl: null })
|
||||
assert.ok(t.text.includes('Muster GmbH'))
|
||||
assert.ok(t.text.includes(euroDe(24000)))
|
||||
assert.ok(t.text.includes('4 Tagen'))
|
||||
assert.deepEqual(only(todos({ invoices: [draft(1)] }), 'entwurf'), [])
|
||||
assert.deepEqual(only(todos({ invoices: [draft(3)] }), 'entwurf'), [])
|
||||
assert.equal(only(todos({ invoices: [draft(40)] }), 'entwurf').length, 1)
|
||||
})
|
||||
|
||||
test('entwurf: andere Status nie, Entwurf ohne Nummer wird nicht mit leerem Namen angezeigt', () => {
|
||||
assert.deepEqual(only(todos({ invoices: [invoice({ status: 'offen', createdDate: '2026-01-01', dueDate: '2026-12-01' })] }), 'entwurf'), [])
|
||||
const [t] = only(todos({ invoices: [invoice({ status: 'entwurf', number: '', createdDate: '2026-09-01' })] }), 'entwurf')
|
||||
assert.ok(!/ {2}|\(\)|undefined|null/.test(t.text))
|
||||
})
|
||||
|
||||
// --- fixkosten-vorschlag -------------------------------------------------------------------------------------
|
||||
|
||||
test('fixkosten-vorschlag: erkannte wiederkehrende Ausgabe -> info mit Vorschlag in der Aktion', () => {
|
||||
const entry = recurring()
|
||||
const [t] = only(todos({ recurring: [entry] }), 'fixkosten-vorschlag')
|
||||
assert.equal(t.dringlichkeit, 'info')
|
||||
assert.equal(t.datum, '2026-09-27')
|
||||
assert.deepEqual(t.aktion, { typ: 'fixkosten-uebernehmen', vorschlag: entry })
|
||||
assert.ok(t.text.includes('Hetzner'))
|
||||
assert.ok(t.text.includes(euroDe(449)))
|
||||
assert.ok(t.text.includes('monatlich'))
|
||||
assert.match(t.text, /27\.09\.2026/)
|
||||
})
|
||||
|
||||
test('fixkosten-vorschlag: nur wenn die letzte Buchung höchstens 60 Tage zurückliegt', () => {
|
||||
const at = (days) => only(todos({ recurring: [recurring({ lastDate: addDays(TODAY, -days) })] }), 'fixkosten-vorschlag').length
|
||||
assert.equal(at(60), 1)
|
||||
assert.equal(at(61), 0)
|
||||
assert.equal(at(0), 1)
|
||||
assert.equal(only(todos({ recurring: [recurring({ lastDate: 'Mai' })] }), 'fixkosten-vorschlag').length, 0)
|
||||
})
|
||||
|
||||
test('fixkosten-vorschlag: Rhythmus im Text', () => {
|
||||
const text = (interval) => only(todos({ recurring: [recurring({ interval })] }), 'fixkosten-vorschlag')[0].text
|
||||
assert.ok(text('quartal').includes('vierteljährlich'))
|
||||
assert.ok(text('jahr').includes('jährlich') && !text('jahr').includes('vierteljährlich'))
|
||||
assert.ok(text('monat').includes('monatlich'))
|
||||
})
|
||||
|
||||
// --- kleinunternehmer ----------------------------------------------------------------------------------------
|
||||
|
||||
test('kleinunternehmer: Anteil 0.72 -> mittel, 1.05 -> hoch, Aktion einstellungen', () => {
|
||||
const [mid] = only(todos({ tax: tax(0.72) }), 'kleinunternehmer')
|
||||
assert.equal(mid.dringlichkeit, 'mittel')
|
||||
assert.deepEqual(mid.aktion, { typ: 'einstellungen' })
|
||||
assert.equal(mid.datum, TODAY)
|
||||
assert.ok(mid.text.includes('72 %'))
|
||||
assert.ok(mid.text.includes(euroDe(1800000)))
|
||||
assert.ok(mid.text.includes(euroDe(2500000)))
|
||||
const [high] = only(todos({ tax: tax(1.05) }), 'kleinunternehmer')
|
||||
assert.equal(high.dringlichkeit, 'hoch')
|
||||
assert.ok(high.text.includes(euroDe(2625000)))
|
||||
})
|
||||
|
||||
test('kleinunternehmer: Schwellen 70 % und 100 %, darunter nichts', () => {
|
||||
const level = (anteil) => only(todos({ tax: tax(anteil) }), 'kleinunternehmer')[0]?.dringlichkeit ?? null
|
||||
assert.equal(level(0.69), null)
|
||||
assert.equal(level(0), null)
|
||||
assert.equal(level(0.7), 'mittel')
|
||||
assert.equal(level(1750000 / 2500000), 'mittel')
|
||||
assert.equal(level(0.99), 'mittel')
|
||||
assert.equal(level(1), 'hoch')
|
||||
assert.equal(level(2.5), 'hoch')
|
||||
})
|
||||
|
||||
test('kleinunternehmer: über 100.000 Euro nennt die harte Grenze', () => {
|
||||
const [t] = only(todos({ tax: tax(4.2, { jahrCents: 10500000 }) }), 'kleinunternehmer')
|
||||
assert.equal(t.dringlichkeit, 'hoch')
|
||||
assert.ok(t.text.includes(euroDe(10000000)))
|
||||
assert.ok(t.text.includes(euroDe(10500000)))
|
||||
})
|
||||
|
||||
test('kleinunternehmer: ohne tax oder mit unlesbarem Anteil keine Aufgabe', () => {
|
||||
assert.deepEqual(only(todos({ tax: null }), 'kleinunternehmer'), [])
|
||||
assert.deepEqual(only(todos({ tax: {} }), 'kleinunternehmer'), [])
|
||||
assert.deepEqual(only(todos({ tax: { kleinunternehmer: { anteilFolgejahr: 'viel' } } }), 'kleinunternehmer'), [])
|
||||
})
|
||||
|
||||
test('kleinunternehmer: ohne jahrCents wird der Betrag aus dem Anteil abgeleitet', () => {
|
||||
const [t] = only(todos({ tax: { kleinunternehmer: { anteilFolgejahr: 0.72 } } }), 'kleinunternehmer')
|
||||
assert.ok(t.text.includes(euroDe(1800000)))
|
||||
})
|
||||
|
||||
// --- Sortierung und Sonstiges --------------------------------------------------------------------------------
|
||||
|
||||
test('Sortierung: hoch vor mittel vor info, darin nach Datum aufsteigend', () => {
|
||||
const list = todos({
|
||||
connection: connection({ validUntil: '2026-10-20T00:00:00.000Z', lastSyncAt: ago(30) }), // mittel (20.10.), mittel (30.09.)
|
||||
invoices: [
|
||||
invoice({ id: 'late', dueDate: '2026-08-01' }), // hoch 01.08.
|
||||
invoice({ id: 'later', dueDate: '2026-08-15' }), // hoch 15.08.
|
||||
invoice({ id: 'draft', status: 'entwurf', createdDate: '2026-09-20' }), // info 20.09.
|
||||
invoice({ id: 'mid', dueDate: '2026-09-29' }), // mittel 29.09.
|
||||
],
|
||||
transactions: [inflow({ bookingDate: '2026-09-10' })], // mittel 10.09.
|
||||
subscriptions: [sub()], // hoch heute
|
||||
recurring: [recurring({ lastDate: '2026-09-15' })], // info 15.09.
|
||||
tax: tax(0.8), // mittel heute
|
||||
})
|
||||
assert.deepEqual(
|
||||
list.map((t) => [t.dringlichkeit, t.datum, t.art]),
|
||||
[
|
||||
['hoch', '2026-08-01', 'ueberfaellig'],
|
||||
['hoch', '2026-08-15', 'ueberfaellig'],
|
||||
['hoch', '2026-10-01', 'abo-zahlung'],
|
||||
['mittel', '2026-09-10', 'zuordnung'],
|
||||
['mittel', '2026-09-29', 'ueberfaellig'],
|
||||
['mittel', '2026-09-30', 'bank-abruf'],
|
||||
['mittel', '2026-10-01', 'kleinunternehmer'],
|
||||
['mittel', '2026-10-20', 'bank-zugang'],
|
||||
['info', '2026-09-15', 'fixkosten-vorschlag'],
|
||||
['info', '2026-09-20', 'entwurf'],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('Sortierung: bei gleichem Datum bleibt die Eingabereihenfolge', () => {
|
||||
const list = todos({ invoices: [invoice({ id: 'b', number: 'WK-B', dueDate: '2026-09-20' }), invoice({ id: 'a', number: 'WK-A', dueDate: '2026-09-20' })] })
|
||||
assert.deepEqual(list.map((t) => t.aktion.invoiceId), ['b', 'a'])
|
||||
})
|
||||
|
||||
test('jede Aufgabe hat Art, Dringlichkeit, Titel, Text, Datum und Aktion', () => {
|
||||
const list = todos({
|
||||
connection: connection({ status: 'abgelaufen', lastSyncAt: ago(30) }),
|
||||
invoices: [invoice(), invoice({ id: 'd', status: 'entwurf', createdDate: '2026-09-01' })],
|
||||
transactions: [inflow()],
|
||||
subscriptions: [sub()],
|
||||
recurring: [recurring()],
|
||||
tax: tax(1.1),
|
||||
})
|
||||
assert.equal(list.length, 7)
|
||||
for (const t of list) {
|
||||
assert.deepEqual(Object.keys(t).sort(), ['aktion', 'art', 'datum', 'dringlichkeit', 'text', 'titel'])
|
||||
assert.ok(['hoch', 'mittel', 'info'].includes(t.dringlichkeit))
|
||||
assert.match(t.datum, /^\d{4}-\d{2}-\d{2}$/)
|
||||
assert.ok(t.titel && t.text)
|
||||
assert.ok(!/undefined|null|NaN/.test(`${t.titel} ${t.text}`))
|
||||
}
|
||||
})
|
||||
|
||||
test('kaputte Datensätze werden übersprungen statt alles zu kippen', () => {
|
||||
const list = todos({
|
||||
invoices: [null, invoice({ dueDate: 'bald' }), invoice({ id: 'x', remainingCents: 1.5 }), invoice({ id: 'ok' })],
|
||||
transactions: [null, inflow({ $id: 'bad', amountCents: 'x' }), inflow({ $id: 'ok' })],
|
||||
subscriptions: [null],
|
||||
recurring: [null, recurring({ lastDate: undefined })],
|
||||
})
|
||||
assert.deepEqual(list.map((t) => t.art).sort(), ['ueberfaellig', 'zuordnung'])
|
||||
})
|
||||
|
||||
test('Eingaben werden nicht verändert', () => {
|
||||
const input = {
|
||||
today: TODAY,
|
||||
now: NOW,
|
||||
connection: connection({ lastSyncAt: ago(30) }),
|
||||
invoices: [invoice()],
|
||||
transactions: [inflow({ vorschlaege: [{ kind: 'invoice', id: 'in_1', number: 'WK-1', confidence: 'hoch', reason: '', amountDiffers: false }] })],
|
||||
subscriptions: [sub()],
|
||||
recurring: [recurring()],
|
||||
tax: tax(0.9),
|
||||
}
|
||||
const copy = JSON.stringify(input)
|
||||
buildTodos(input)
|
||||
assert.equal(JSON.stringify(input), copy)
|
||||
})
|
||||
Reference in new issue
Block a user