diff --git a/server/services/finance/tax.js b/server/services/finance/tax.js new file mode 100644 index 0000000..f937d99 --- /dev/null +++ b/server/services/finance/tax.js @@ -0,0 +1,86 @@ +/** + * Steuer-Auswertung der Finanzen (rein, ohne I/O): Rücklage auf den Gewinn des laufenden Jahres, Zähler für die + * Kleinunternehmer-Grenzen (§ 19 UStG) und die nächste Einkommensteuer-Vorauszahlung. Beträge sind ganze Cent; + * "heute" kommt als Berliner Kalendertag von außen. + */ +import { daysBetween } from './dates.js' + +const GRENZE_FOLGEJAHR_CENTS = 2_500_000 // 25.000 EUR: wer im Jahr darüber liegt, ist im Folgejahr kein Kleinunternehmer mehr +const GRENZE_HART_CENTS = 10_000_000 // 100.000 EUR: darüber endet die Regelung sofort +const GEWERBESTEUER_FREIBETRAG_CENTS = 2_450_000 // 24.500 EUR Gewinn bei Personengesellschaften +const DEFAULT_RESERVE_PERCENT = 30 // wie DEFAULT_SETTINGS.taxReservePercent (settings.js, hier nicht importiert: Modul bleibt rein) +const EST_MONTHS = [3, 6, 9, 12] // ESt-Vorauszahlung jeweils am 10. +const EST_DAY = 10 + +const isCents = (n) => Number.isSafeInteger(n) +const pad = (n) => String(n).padStart(2, '0') + +/** Nächster ESt-Vorauszahlungstermin ab heute (heute zählt mit) als YYYY-MM-DD. */ +function nextEstDate(today) { + const year = Number(today.slice(0, 4)) + for (const month of EST_MONTHS) { + const date = `${String(year).padStart(4, '0')}-${pad(month)}-${pad(EST_DAY)}` + if (date >= today) return date + } + return `${String(year + 1).padStart(4, '0')}-${pad(EST_MONTHS[0])}-${pad(EST_DAY)}` +} + +/** + * @param {object} args + * @param {string} args.today Berliner Tag YYYY-MM-DD + * @param {Array} [args.transactions] Bankbuchungen { status, bookingDate, amountCents (+ Eingang / - Ausgang), category } + * @param {Array} [args.invoices] Stripe-Rechnungen (gemappt) { paidCents, paidDate } + * @param {object} [args.settings] { taxReservePercent, previousYearRevenueCents, estPrepaymentCents } + * + * Einnahmen und Ausgaben: gebuchte Bankbuchungen (BOOK) des Kalenderjahres von today ohne Kategorie "privat". + * Der Kleinunternehmer-Zähler nimmt statt der Bankeingänge die Zahltage der Stripe-Rechnungen plus Buchungen + * "rechnung-extern"; Stripe-Auszahlungen und Rechnungsbuchungen zählen dort nicht (sonst doppelt). + */ +export function computeTax({ today, transactions = [], invoices = [], settings } = {}) { + daysBetween(today, today) // wirft bei einem ungültigen Tag + const jahr = Number(today.slice(0, 4)) + const prefix = `${today.slice(0, 4)}-` + + let einnahmenCents = 0 + let ausgabenCents = 0 + let externCents = 0 + for (const tx of transactions || []) { + if (!tx || tx.status !== 'BOOK' || typeof tx.bookingDate !== 'string' || !tx.bookingDate.startsWith(prefix)) continue + if (!isCents(tx.amountCents) || tx.category === 'privat') continue + if (tx.amountCents > 0) { + einnahmenCents += tx.amountCents + if (tx.category === 'rechnung-extern') externCents += tx.amountCents + } else { + ausgabenCents -= tx.amountCents + } + } + + let stripeCents = 0 + for (const invoice of invoices || []) { + if (!invoice || typeof invoice.paidDate !== 'string' || !invoice.paidDate.startsWith(prefix)) continue + if (isCents(invoice.paidCents) && invoice.paidCents > 0) stripeCents += invoice.paidCents + } + + const gewinnCents = einnahmenCents - ausgabenCents + const prozent = Number.isFinite(settings?.taxReservePercent) ? settings.taxReservePercent : DEFAULT_RESERVE_PERCENT + const jahrCents = stripeCents + externCents + + const prepayment = settings?.estPrepaymentCents + return { + jahr, + einnahmenCents, + ausgabenCents, + gewinnCents, + ruecklageCents: Math.round((Math.max(0, gewinnCents) * prozent) / 100), + prozent, + gewerbesteuerHinweis: gewinnCents > GEWERBESTEUER_FREIBETRAG_CENTS, + kleinunternehmer: { + jahrCents, + vorjahrCents: isCents(settings?.previousYearRevenueCents) ? settings.previousYearRevenueCents : null, + grenzeFolgejahrCents: GRENZE_FOLGEJAHR_CENTS, + grenzeHartCents: GRENZE_HART_CENTS, + anteilFolgejahr: jahrCents / GRENZE_FOLGEJAHR_CENTS, + }, + naechsteVorauszahlung: isCents(prepayment) && prepayment > 0 ? { date: nextEstDate(today), amountCents: prepayment } : null, + } +} diff --git a/server/services/finance/todos.js b/server/services/finance/todos.js new file mode 100644 index 0000000..c807889 --- /dev/null +++ b/server/services/finance/todos.js @@ -0,0 +1,332 @@ +/** + * Aufgabenliste "Zu erledigen" der Finanzen (rein, ohne I/O). Jede Aufgabe gehört zu einer festen Art + * (Spec "Zu erledigen – Arten"), hat eine Dringlichkeit (hoch, mittel, info), einen deutschen Titel und Text, + * ein Datum (YYYY-MM-DD, wonach innerhalb der Dringlichkeit sortiert wird) und eine Aktion für die Oberfläche. + * Beträge sind ganze Cent; "heute" (Berliner Tag) und "jetzt" kommen von außen. Kaputte Datensätze fallen weg, + * statt die ganze Liste zu kippen. + */ +import { addDays, berlinDate, daysBetween, formatDe } from './dates.js' +import { euroDe } from './format.js' + +const HOCH = 'hoch' +const MITTEL = 'mittel' +const INFO = 'info' +const RANK = { [HOCH]: 0, [MITTEL]: 1, [INFO]: 2 } + +const BANK_WARN_DAYS = 30 // Zugang läuft in <= 30 Tagen ab: mittel +const BANK_URGENT_DAYS = 14 // ... in <= 14 Tagen: hoch +const SYNC_MAX_AGE_MS = 24 * 3_600_000 +const OVERDUE_URGENT_DAYS = 30 // ab 30 Tagen Verzug: hoch +const DRAFT_MAX_AGE_DAYS = 3 // Entwurf älter als 3 Tage +const RECURRING_MAX_AGE_DAYS = 60 // Vorschläge nur für Ausgaben, die zuletzt in den letzten 60 Tagen liefen +const KLEIN_WARN_SHARE = 0.7 +// Wie in tax.js; gelten nur, wenn das tax-Objekt die Grenzen nicht mitgibt +const KLEIN_LIMIT_CENTS = 2_500_000 +const KLEIN_HARD_LIMIT_CENTS = 10_000_000 +const MAX_REMITTANCE = 80 + +const INTERVAL_TEXT = { monat: 'monatlich', quartal: 'vierteljährlich', jahr: 'jährlich' } + +const isCents = (n) => Number.isSafeInteger(n) + +/** Wert, der ein echter Kalendertag sein muss, sonst null. */ +function validYmd(ymd) { + try { + daysBetween(ymd, ymd) + return ymd + } catch { + return null + } +} + +/** Zeitstempel (ISO-Text) als Date, null bei fehlendem oder unlesbarem Wert. */ +function parseInstant(iso) { + if (!iso || typeof iso !== 'string') return null + const date = new Date(iso) + return Number.isNaN(date.getTime()) ? null : date +} + +const trimmed = (value) => (typeof value === 'string' ? value.trim() : '') + +/** "1 Tag" / "5 Tage" (Nominativ) bzw. "1 Tag" / "5 Tagen" (Dativ). */ +const days = (n, dative = false) => `${n} ${n === 1 ? 'Tag' : dative ? 'Tagen' : 'Tage'}` + +const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text) + +function todo(art, dringlichkeit, titel, text, datum, aktion) { + return { art, dringlichkeit, titel, text, datum, aktion } +} + +// --- Bank --------------------------------------------------------------------------------------------------- + +function bankTodos({ today, now, connection }) { + const result = [] + if (!connection) { + result.push( + todo( + 'bank-zugang', + HOCH, + 'Bankkonto verbinden', + 'Es ist noch kein Bankkonto verbunden. Verbinden Sie das Konto, damit Zahlungseingänge abgerufen werden.', + today, + { typ: 'bank-verbinden' } + ) + ) + return result + } + + const validUntilDate = parseInstant(connection.validUntil) + const validUntil = validUntilDate ? berlinDate(validUntilDate) : null + const daysLeft = validUntil ? daysBetween(today, validUntil) : null + const expired = connection.status === 'abgelaufen' || (daysLeft !== null && daysLeft < 0) + + if (expired) { + const when = validUntil && validUntil < today ? `am ${formatDe(validUntil)} abgelaufen` : 'abgelaufen oder widerrufen worden' + result.push( + todo( + 'bank-zugang', + HOCH, + 'Bankzugang abgelaufen', + `Der Zugang zum Bankkonto ist ${when}. Verbinden Sie das Konto neu, damit wieder Buchungen abgerufen werden.`, + validUntil && validUntil < today ? validUntil : today, + { typ: 'bank-verbinden' } + ) + ) + } else if (daysLeft !== null && daysLeft <= BANK_WARN_DAYS) { + const phrase = daysLeft === 0 ? 'heute' : daysLeft === 1 ? 'morgen' : `in ${days(daysLeft, true)}` + result.push( + todo( + 'bank-zugang', + daysLeft <= BANK_URGENT_DAYS ? HOCH : MITTEL, + 'Bankzugang läuft ab', + `Der Zugang zum Bankkonto läuft am ${formatDe(validUntil)} ab (${phrase}). Verbinden Sie das Konto neu, damit der Abruf nicht unterbrochen wird.`, + validUntil, + { typ: 'bank-verbinden' } + ) + ) + } + + if (connection.status === 'aktiv') { + const lastSync = parseInstant(connection.lastSyncAt) + const stale = !lastSync || now.getTime() - lastSync.getTime() > SYNC_MAX_AGE_MS + if (connection.lastSyncOk === false || stale) { + const lastDay = lastSync ? berlinDate(lastSync) : today + const failed = connection.lastSyncOk === false + const error = trimmed(connection.lastError) + const last = lastSync ? `Der letzte erfolgreiche Abruf war am ${formatDe(lastDay)}.` : 'Es gab noch keinen erfolgreichen Abruf.' + result.push( + todo( + 'bank-abruf', + MITTEL, + failed ? 'Bankabruf fehlgeschlagen' : 'Bankabruf veraltet', + `${last}${failed && error ? ` Fehler: ${error}` : ''} Rufen Sie die Buchungen jetzt neu ab.`, + lastDay, + { typ: 'bank-aktualisieren' } + ) + ) + } + } + return result +} + +// --- Rechnungen --------------------------------------------------------------------------------------------- + +function overdueTodos({ today, invoices }) { + const result = [] + for (const invoice of invoices) { + if (!invoice || (invoice.status !== 'ueberfaellig' && invoice.status !== 'offen')) continue + const due = validYmd(invoice.dueDate) + if (!due || due >= today || !isCents(invoice.remainingCents)) continue + const late = daysBetween(due, today) + const customer = trimmed(invoice.customerName) + result.push( + todo( + 'ueberfaellig', + late >= OVERDUE_URGENT_DAYS ? HOCH : MITTEL, + ['Rechnung', trimmed(invoice.number), 'überfällig'].filter(Boolean).join(' '), + `${customer ? `${customer}: ` : ''}${euroDe(invoice.remainingCents)} offen, fällig am ${formatDe(due)} (${days(late)} überfällig).`, + due, + { typ: 'rechnung-oeffnen', invoiceId: invoice.id, hostedUrl: invoice.hostedUrl || null } + ) + ) + } + return result +} + +function draftTodos({ today, invoices }) { + const result = [] + for (const invoice of invoices) { + if (!invoice || invoice.status !== 'entwurf') continue + const created = validYmd(invoice.createdDate) + if (!created) continue + const age = daysBetween(created, today) + if (age <= DRAFT_MAX_AGE_DAYS) continue + const number = trimmed(invoice.number) + const customer = trimmed(invoice.customerName) + const amount = isCents(invoice.totalCents) ? invoice.totalCents : invoice.remainingCents + result.push( + todo( + 'entwurf', + INFO, + 'Rechnungsentwurf nicht versendet', + `Der Entwurf${number ? ` ${number}` : ''}${customer ? ` für ${customer}` : ''}${isCents(amount) ? ` über ${euroDe(amount)}` : ''} wurde vor ${days(age, true)} angelegt und noch nicht gesendet.`, + created, + { typ: 'rechnung-oeffnen', invoiceId: invoice.id, hostedUrl: invoice.hostedUrl || null } + ) + ) + } + return result +} + +// --- Buchungen ---------------------------------------------------------------------------------------------- + +/** Erster Vorschlag mit Sicherheit "hoch" als Satzende, sonst leer. */ +function hint(transaction) { + const best = Array.isArray(transaction.vorschlaege) ? transaction.vorschlaege.find((item) => item?.confidence === 'hoch') : null + if (!best) return '' + if (best.kind === 'payout') return 'Vorschlag: Stripe-Auszahlung' + if (best.kind === 'invoice') return `Vorschlag: Rechnung ${trimmed(best.number) || best.id || ''}`.trimEnd() + return '' +} + +function assignTodos({ transactions }) { + const result = [] + for (const tx of transactions) { + if (!tx || tx.status !== 'BOOK' || tx.category || !isCents(tx.amountCents) || tx.amountCents <= 0) continue + const date = validYmd(tx.bookingDate) + if (!date) continue + const from = trimmed(tx.counterpartyName) + const remittance = clip(trimmed(tx.remittance), MAX_REMITTANCE) + const suggestion = hint(tx) + result.push( + todo( + 'zuordnung', + MITTEL, + 'Zahlungseingang zuordnen', + `${euroDe(tx.amountCents)}${from ? ` von ${from}` : ''} am ${formatDe(date)}${remittance ? `: „${remittance}“` : ''}.${suggestion ? ` ${suggestion}` : ''}`, + date, + { typ: 'zuordnen', transactionId: tx.$id } + ) + ) + } + return result +} + +function recurringTodos({ today, recurring }) { + const result = [] + const earliest = addDays(today, -RECURRING_MAX_AGE_DAYS) + for (const entry of recurring) { + const last = entry ? validYmd(entry.lastDate) : null + if (!last || last < earliest || !isCents(entry.amountCents)) continue + const rhythm = INTERVAL_TEXT[entry.interval] + result.push( + todo( + 'fixkosten-vorschlag', + INFO, + 'Wiederkehrende Ausgabe erkannt', + `${trimmed(entry.name) || 'Eine Ausgabe'}: ${euroDe(entry.amountCents)}${rhythm ? ` ${rhythm}` : ''}, zuletzt am ${formatDe(last)}${isCents(entry.count) ? ` (${entry.count} Buchungen)` : ''}. Als Fixkosten übernehmen?`, + last, + { typ: 'fixkosten-uebernehmen', vorschlag: entry } + ) + ) + } + return result +} + +// --- Abos und Kleinunternehmer ------------------------------------------------------------------------------ + +function subscriptionTodos({ today, subscriptions }) { + const result = [] + for (const subscription of subscriptions) { + if (!subscription || (subscription.status !== 'past_due' && subscription.status !== 'unpaid')) continue + const failed = subscription.status === 'past_due' + const customer = trimmed(subscription.customerName) + const product = trimmed(subscription.productKey) || trimmed(subscription.planKey) + result.push( + todo( + 'abo-zahlung', + HOCH, + failed ? 'Abo-Zahlung fehlgeschlagen' : 'Abo unbezahlt', + `${customer ? `${customer}: ` : ''}Das Abo${product ? ` „${product}“` : ''}${isCents(subscription.priceAmount) ? ` (${euroDe(subscription.priceAmount)})` : ''} ${failed ? 'konnte nicht abgebucht werden.' : 'ist unbezahlt: Die Zahlung blieb aus.'}`, + today, + { typ: 'kunde-oeffnen', customerId: subscription.customerId } + ) + ) + } + return result +} + +function smallBusinessTodos({ today, tax }) { + const small = tax?.kleinunternehmer + const share = small?.anteilFolgejahr + if (typeof share !== 'number' || !Number.isFinite(share) || share < KLEIN_WARN_SHARE) return [] + + const limit = isCents(small.grenzeFolgejahrCents) ? small.grenzeFolgejahrCents : KLEIN_LIMIT_CENTS + const hardLimit = isCents(small.grenzeHartCents) ? small.grenzeHartCents : KLEIN_HARD_LIMIT_CENTS + const revenue = isCents(small.jahrCents) ? small.jahrCents : Math.round(share * limit) + const percent = Math.floor(share * 100 + 1e-9) + const year = today.slice(0, 4) + + if (revenue > hardLimit) { + return [ + todo( + 'kleinunternehmer', + HOCH, + 'Kleinunternehmer-Grenze überschritten', + `Die Einnahmen ${year} betragen ${euroDe(revenue)} und liegen über der Grenze von ${euroDe(hardLimit)}. Die Kleinunternehmerregelung (§ 19 UStG) ist davon sofort betroffen. Bitte umgehend mit der Steuerberatung klären.`, + today, + { typ: 'einstellungen' } + ), + ] + } + if (share >= 1) { + return [ + todo( + 'kleinunternehmer', + HOCH, + 'Kleinunternehmer-Grenze erreicht', + `Die Einnahmen ${year} betragen ${euroDe(revenue)} (${percent} % der Grenze von ${euroDe(limit)}). Über ${euroDe(limit)} endet die Kleinunternehmerregelung (§ 19 UStG) im Folgejahr, über ${euroDe(hardLimit)} schon im laufenden Jahr. Bitte mit der Steuerberatung klären.`, + today, + { typ: 'einstellungen' } + ), + ] + } + return [ + todo( + 'kleinunternehmer', + MITTEL, + 'Kleinunternehmer-Grenze rückt näher', + `Die Einnahmen ${year} liegen bei ${euroDe(revenue)} und damit bei ${percent} % der Grenze von ${euroDe(limit)}. Wird sie überschritten, endet die Kleinunternehmerregelung (§ 19 UStG) im Folgejahr.`, + today, + { typ: 'einstellungen' } + ), + ] +} + +/** + * @param {object} args + * @param {string} args.today Berliner Tag YYYY-MM-DD + * @param {Date} args.now Zeitpunkt (Alter des letzten Bankabrufs) + * @param {object|null} args.connection Bankverbindung { status, validUntil, lastSyncAt, lastSyncOk, lastError } oder null + * @param {Array} [args.invoices] gemappte Stripe-Rechnungen + * @param {Array} [args.transactions] Bankbuchungen, optional mit vorschlaege + * @param {Array} [args.subscriptions] Abos { customerId, status, priceAmount, productKey|planKey, customerName? } + * @param {Array} [args.recurring] erkannte wiederkehrende Ausgaben (detectRecurring) + * @param {object} [args.tax] Ergebnis von computeTax + * @returns {Array<{ art, dringlichkeit, titel, text, datum, aktion }>} hoch vor mittel vor info, darin ältestes Datum zuerst + */ +export function buildTodos({ today, now = new Date(), connection, invoices, transactions, subscriptions, recurring, tax } = {}) { + daysBetween(today, today) // wirft bei einem ungültigen Tag + const list = (value) => (Array.isArray(value) ? value : []) + const all = [ + ...bankTodos({ today, now, connection }), + ...overdueTodos({ today, invoices: list(invoices) }), + ...assignTodos({ transactions: list(transactions) }), + ...subscriptionTodos({ today, subscriptions: list(subscriptions) }), + ...draftTodos({ today, invoices: list(invoices) }), + ...recurringTodos({ today, recurring: list(recurring) }), + ...smallBusinessTodos({ today, tax }), + ] + // Array.prototype.sort ist stabil: bei gleicher Dringlichkeit und gleichem Datum bleibt die Reihenfolge der Arten + return all.sort((a, b) => RANK[a.dringlichkeit] - RANK[b.dringlichkeit] || (a.datum < b.datum ? -1 : a.datum > b.datum ? 1 : 0)) +} diff --git a/test/financeTax.test.js b/test/financeTax.test.js new file mode 100644 index 0000000..c0cfd37 --- /dev/null +++ b/test/financeTax.test.js @@ -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) +}) diff --git a/test/financeTodos.test.js b/test/financeTodos.test.js new file mode 100644 index 0000000..418d28a --- /dev/null +++ b/test/financeTodos.test.js @@ -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 “ 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) +})