diff --git a/server/services/finance/forecast.js b/server/services/finance/forecast.js new file mode 100644 index 0000000..7aaa18a --- /dev/null +++ b/server/services/finance/forecast.js @@ -0,0 +1,187 @@ +import { addDays, berlinDate, daysBetween } from './dates.js' + +/** + * Liquiditaetsvorschau (rein, ohne I/O). Zwei Linien ueber denselben Zeitraum (heute bis heute + horizonDays, + * beide Tage eingeschlossen): + * - "sicher": Stripe-Auszahlungen, die schon unterwegs sind, Fixkosten und ESt-Vorauszahlungen. + * - "erwartet": offene Rechnungen und Abo-Verlaengerungen; sie kommen zu den sicheren Posten dazu. + * Betraege sind ganze Cent mit Vorzeichen (+ Zufluss, - Abfluss), Tage YYYY-MM-DD; "heute" kommt von aussen. + */ + +export const FORECAST_ANNAHME = + 'Erwartet: offene Rechnungen zum Fälligkeitstag, überfällige in 14 Tagen, Abo-Verlängerungen 4 Tage nach Abbuchung, vor Stripe-Gebühren.' + +const OVERDUE_DELAY_DAYS = 14 // ueberfaellige Rechnungen: Zahlung in 14 Tagen angenommen +const PAYOUT_DELAY_DAYS = 4 // Abo-Abbuchung -> Auszahlung auf das Konto +const INTERVAL_MONTHS = { monat: 1, quartal: 3, jahr: 12 } +const EST_MONTHS = [3, 6, 9, 12] // ESt-Vorauszahlung jeweils am 10. +const EST_DAY = 10 + +const SICHER = 'sicher' +const ERWARTET = 'erwartet' + +const pad = (n) => String(n).padStart(2, '0') +const isCents = (n) => Number.isSafeInteger(n) + +/** Ein Wert, der ein echter Kalendertag sein muss, sonst null (kaputte Datensaetze fallen weg, statt alles zu kippen). */ +function validYmd(ymd) { + try { + daysBetween(ymd, ymd) + return ymd + } catch { + return null + } +} + +/** Zeitstempel (ISO-Text) als Berliner Kalendertag, null bei fehlendem oder unlesbarem Wert. */ +function berlinDayOf(iso) { + if (!iso) return null + const date = new Date(iso) + return Number.isNaN(date.getTime()) ? null : berlinDate(date) +} + +/** + * ymd plus n Monate. Der Tag bleibt der Tag des Ausgangsdatums, wird aber auf das Monatsende begrenzt: + * 31.10. + 1 = 30.11., + 2 = 31.12. (immer vom Ausgangsdatum aus gerechnet, nie vom bereits begrenzten Termin). + */ +function addMonths(ymd, n) { + const [year, month, day] = ymd.split('-').map(Number) + const total = year * 12 + (month - 1) + n + const newYear = Math.floor(total / 12) + const newMonth = total % 12 + const lastDay = new Date(Date.UTC(newYear, newMonth + 1, 0)).getUTCDate() + return `${String(newYear).padStart(4, '0')}-${pad(newMonth + 1)}-${pad(Math.min(day, lastDay))}` +} + +/** Monatliche Folge ab startYmd (k = 0, 1, 2, ...); step = Monate zwischen zwei Terminen. */ +function* recurring(startYmd, step) { + for (let k = 0; ; k++) yield addMonths(startYmd, k * step) +} + +/** + * Buchungstage einer wiederkehrenden Folge im Zeitraum [from, to]; Termine vor from werden vorgespult. + * Die Termine liegen aufsteigend, die Schleife endet mit dem ersten Termin nach `to`. + */ +function occurrencesIn(startYmd, step, from, to) { + const result = [] + for (const date of recurring(startYmd, step)) { + if (date > to) break + if (date >= from) result.push(date) + } + return result +} + +/** + * @param {object} args + * @param {string} args.today Berliner Tag (YYYY-MM-DD) + * @param {number} [args.horizonDays=30] + * @param {number} args.balanceCents aktueller Kontostand + * @param {Array} [args.payouts] { id, amountCents, arrivalDate, status } + * @param {Array} [args.invoices] { id, number, status, dueDate, remainingCents, customerName } + * @param {Array} [args.subscriptions] Dokumente der Abo-Sammlung (status, priceAmount, currentPeriodEnd, cancelAtPeriodEnd, cancelAt) + * @param {Array} [args.fixedCosts] { $id, name, amountCents, interval, nextDate, active } + * @param {object} [args.settings] { estPrepaymentCents, ... } + */ +export function buildForecast({ + today, + horizonDays = 30, + balanceCents, + payouts = [], + invoices = [], + subscriptions = [], + fixedCosts = [], + settings, +}) { + if (!isCents(balanceCents)) throw new Error('balanceCents muss eine ganze Zahl sein.') + if (!Number.isInteger(horizonDays) || horizonDays < 0) throw new Error('horizonDays muss eine ganze Zahl ab 0 sein.') + const end = addDays(today, horizonDays) // wirft bei einem ungueltigen "heute" + const inRange = (ymd) => ymd >= today && ymd <= end + + const posten = [] + const add = (date, label, amountCents, sicherheit) => posten.push({ date, label, amountCents, sicherheit }) + + // --- sicher ------------------------------------------------------------------------------------------------- + for (const payout of payouts || []) { + if (payout?.status !== 'pending' && payout?.status !== 'in_transit') continue + if (!isCents(payout.amountCents) || payout.amountCents === 0) continue + const arrival = validYmd(payout.arrivalDate) + if (arrival && inRange(arrival)) add(arrival, 'Stripe-Auszahlung', payout.amountCents, SICHER) + } + + for (const cost of fixedCosts || []) { + if (!cost || cost.active === false) continue + const step = INTERVAL_MONTHS[cost.interval] + const start = validYmd(cost.nextDate) + if (!step || !start || !isCents(cost.amountCents) || cost.amountCents <= 0) continue + for (const date of occurrencesIn(start, step, today, end)) add(date, cost.name || 'Fixkosten', -cost.amountCents, SICHER) + } + + const prepayment = settings?.estPrepaymentCents + if (isCents(prepayment) && prepayment > 0) { + const firstYear = Number(today.slice(0, 4)) + const lastYear = Number(end.slice(0, 4)) + for (let year = firstYear; year <= lastYear; year++) { + for (const month of EST_MONTHS) { + const date = `${String(year).padStart(4, '0')}-${pad(month)}-${pad(EST_DAY)}` + if (inRange(date)) add(date, 'ESt-Vorauszahlung', -prepayment, SICHER) + } + } + } + + // --- erwartet ----------------------------------------------------------------------------------------------- + for (const invoice of invoices || []) { + if (invoice?.status !== 'offen' && invoice?.status !== 'ueberfaellig') continue + if (!isCents(invoice.remainingCents) || invoice.remainingCents <= 0) continue + const due = validYmd(invoice.dueDate) + // "offen" mit einem Faelligkeitstag vor heute (Daten von gestern) wird wie "ueberfaellig" behandelt + const overdue = invoice.status === 'ueberfaellig' || (due !== null && due < today) + const date = overdue ? addDays(today, OVERDUE_DELAY_DAYS) : due + if (!date || !inRange(date)) continue + const label = `Rechnung ${invoice.number || ''}`.trim() + (invoice.customerName ? ` · ${invoice.customerName}` : '') + add(date, overdue ? `${label} (überfällig, angenommen)` : label, invoice.remainingCents, ERWARTET) + } + + for (const subscription of subscriptions || []) { + if (subscription?.status !== 'active') continue + if (!isCents(subscription.priceAmount) || subscription.priceAmount <= 0) continue + const periodEnd = berlinDayOf(subscription.currentPeriodEnd) + if (!periodEnd) continue + const cancelAt = berlinDayOf(subscription.cancelAt) + for (const renewal of recurring(periodEnd, 1)) { + const payoutDate = addDays(renewal, PAYOUT_DELAY_DAYS) + if (payoutDate > end) break + if (cancelAt && renewal >= cancelAt) break // gekuendigt: ab dem Kuendigungstag keine Verlaengerung mehr + if (subscription.cancelAtPeriodEnd && renewal > periodEnd) break // zum Periodenende gekuendigt: nur die laufende Periode + if (payoutDate >= today) add(payoutDate, 'Abo-Verlängerung', subscription.priceAmount, ERWARTET) + } + } + + // --- Reihenfolge: nach Datum, am selben Tag Abfluesse zuerst (stabil, sonst in Eingangsreihenfolge) ----------- + posten.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : (a.amountCents < 0 ? 0 : 1) - (b.amountCents < 0 ? 0 : 1))) + + // --- Verlauf je Linie: tiefster laufender Saldo inklusive Startwert, bei Gleichstand das fruehere Datum ------- + const line = (includes) => { + let balance = balanceCents + let min = { amountCents: balance, date: today } + for (const item of posten) { + if (!includes(item)) continue + balance += item.amountCents + if (balance < min.amountCents) min = { amountCents: balance, date: item.date } + } + return { min, end: balance } + } + const sicher = line((item) => item.sicherheit === SICHER) + const erwartet = line(() => true) + + const fixkosten30Cents = posten.reduce((sum, item) => (item.sicherheit === SICHER && item.amountCents < 0 ? sum - item.amountCents : sum), 0) + + return { + posten, + minSicher: sicher.min, + minErwartet: erwartet.min, + endeSicherCents: sicher.end, + endeErwartetCents: erwartet.end, + fixkosten30Cents, + annahme: FORECAST_ANNAHME, + } +} diff --git a/test/financeForecast.test.js b/test/financeForecast.test.js new file mode 100644 index 0000000..8d6ccf5 --- /dev/null +++ b/test/financeForecast.test.js @@ -0,0 +1,278 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { buildForecast } from '../server/services/finance/forecast.js' + +const TODAY = '2026-10-01' +const BALANCE = 10893 +const base = (extra = {}) => buildForecast({ today: TODAY, balanceCents: BALANCE, settings: { estPrepaymentCents: 0 }, ...extra }) +const dates = (result, sicherheit) => result.posten.filter((p) => !sicherheit || p.sicherheit === sicherheit).map((p) => p.date) +const monthly = (extra = {}) => ({ $id: 'fk1', name: 'Hetzner', amountCents: 449, interval: 'monat', nextDate: '2026-09-27', active: true, ...extra }) +const invoice = (extra = {}) => ({ id: 'in_1', number: 'WK-2026-0001', status: 'offen', dueDate: '2026-10-14', remainingCents: 50000, customerName: 'Muster GmbH', ...extra }) +const sub = (extra = {}) => ({ + customerId: 'c1', status: 'active', priceAmount: 2000, currentPeriodEnd: '2026-10-05T10:00:00.000+00:00', cancelAtPeriodEnd: false, cancelAt: null, + ...extra, +}) + +test('nur Kontostand: keine Posten, beide Minima gleich dem Startwert am heutigen Tag', () => { + const r = base() + assert.deepEqual(r.posten, []) + assert.deepEqual(r.minSicher, { amountCents: 10893, date: TODAY }) + assert.deepEqual(r.minErwartet, { amountCents: 10893, date: TODAY }) + assert.equal(r.endeSicherCents, 10893) + assert.equal(r.endeErwartetCents, 10893) + assert.equal(r.fixkosten30Cents, 0) + assert.equal(r.annahme, 'Erwartet: offene Rechnungen zum Fälligkeitstag, überfällige in 14 Tagen, Abo-Verlängerungen 4 Tage nach Abbuchung, vor Stripe-Gebühren.') +}) + +test('Standardwerte: Auszahlungen, Rechnungen, Abos und Fixkosten dürfen fehlen, Horizont 30 Tage', () => { + const r = buildForecast({ today: TODAY, balanceCents: BALANCE }) + assert.deepEqual(r.posten, []) + const r2 = buildForecast({ today: TODAY, balanceCents: BALANCE, payouts: null, invoices: null, subscriptions: null, fixedCosts: null, settings: null }) + assert.deepEqual(r2.posten, []) + // Horizont 30: der 31.10. liegt noch drin, der 01.11. nicht + const r3 = base({ fixedCosts: [monthly({ nextDate: '2026-10-31' }), monthly({ $id: 'fk2', name: 'Spaet', nextDate: '2026-11-01' })] }) + assert.deepEqual(r3.posten.map((p) => p.date), ['2026-10-31']) +}) + +test('Fixkosten 449 monatlich ab 27.09.: Termin 27.10. (vorgespult), tiefster Stand 10444 am 27.10.', () => { + const r = base({ fixedCosts: [monthly()] }) + assert.deepEqual(r.posten, [{ date: '2026-10-27', label: 'Hetzner', amountCents: -449, sicherheit: 'sicher' }]) + assert.deepEqual(r.minSicher, { amountCents: 10444, date: '2026-10-27' }) + assert.deepEqual(r.minErwartet, { amountCents: 10444, date: '2026-10-27' }) + assert.equal(r.endeSicherCents, 10444) + assert.equal(r.fixkosten30Cents, 449) +}) + +test('Fixkosten: Termin genau heute zählt, gestern nicht; inaktive und ungültige werden ignoriert', () => { + const r = base({ + fixedCosts: [ + monthly({ $id: 'a', name: 'Heute', nextDate: TODAY }), + monthly({ $id: 'b', name: 'Inaktiv', nextDate: '2026-10-10', active: false }), + monthly({ $id: 'c', name: 'Kaputt', nextDate: 'morgen' }), + monthly({ $id: 'd', name: 'Keine Zahl', amountCents: 0, nextDate: '2026-10-10' }), + monthly({ $id: 'e', name: 'Unbekannt', interval: 'woche', nextDate: '2026-10-10' }), + ], + }) + assert.deepEqual(r.posten.map((p) => [p.date, p.label]), [[TODAY, 'Heute']]) +}) + +test('Fixkosten am 31.: Tag auf Monatsende begrenzt, aber vom ursprünglichen Tag gerechnet', () => { + // Der 31.01.2027 liegt 122 Tage nach dem 01.10.2026; Horizont 120 endet am 29.01. und enthält ihn nicht + const r = base({ horizonDays: 122, fixedCosts: [monthly({ nextDate: '2026-10-31' })] }) + assert.deepEqual(dates(r), ['2026-10-31', '2026-11-30', '2026-12-31', '2027-01-31']) + assert.deepEqual(dates(base({ horizonDays: 121, fixedCosts: [monthly({ nextDate: '2026-10-31' })] })), ['2026-10-31', '2026-11-30', '2026-12-31']) + assert.deepEqual(dates(base({ horizonDays: 120, fixedCosts: [monthly({ nextDate: '2026-10-31' })] })), ['2026-10-31', '2026-11-30', '2026-12-31']) +}) + +test('Fixkosten: Februar im Schaltjahr und Vorspulen aus der Vergangenheit mit Klammerung', () => { + const r = buildForecast({ today: '2028-02-01', horizonDays: 60, balanceCents: 0, fixedCosts: [monthly({ nextDate: '2027-11-30' })] }) + // 30.11.2027 -> 30.12. -> 30.01. (vorgespult) -> 29.02.2028 (Schaltjahr, Klammerung) -> 30.03.2028; Horizontende 01.04.2028 + assert.deepEqual(dates(r), ['2028-02-29', '2028-03-30']) + const r2 = buildForecast({ today: '2026-10-01', horizonDays: 200, balanceCents: 0, fixedCosts: [monthly({ nextDate: '2026-01-31' })] }) + assert.deepEqual(dates(r2), ['2026-10-31', '2026-11-30', '2026-12-31', '2027-01-31', '2027-02-28', '2027-03-31']) +}) + +test('Fixkosten quartalsweise und jährlich', () => { + const quartal = monthly({ $id: 'q', name: 'Quartal', amountCents: 3000, interval: 'quartal', nextDate: '2026-07-15' }) + const jahr = monthly({ $id: 'j', name: 'Jahr', amountCents: 12000, interval: 'jahr', nextDate: '2025-12-20' }) + const r = base({ horizonDays: 120, fixedCosts: [quartal, jahr] }) + assert.deepEqual(r.posten.map((p) => [p.date, p.label, p.amountCents]), [ + ['2026-10-15', 'Quartal', -3000], + ['2026-12-20', 'Jahr', -12000], + ['2027-01-15', 'Quartal', -3000], + ]) + assert.equal(r.fixkosten30Cents, 18000) + // Quartal: 31.12. -> 31.03. -> 30.06. (vom Ausgangstag 31 gerechnet) + const q31 = base({ horizonDays: 460, fixedCosts: [monthly({ interval: 'quartal', nextDate: '2026-12-31' })] }) + assert.deepEqual(dates(q31), ['2026-12-31', '2027-03-31', '2027-06-30', '2027-09-30', '2027-12-31']) +}) + +test('offene Rechnung 50000 fällig am 14.10.: erwartet am Fälligkeitstag, Ende erwartet 60893, Ende sicher 10893', () => { + const r = base({ invoices: [invoice()] }) + assert.deepEqual(r.posten, [{ date: '2026-10-14', label: 'Rechnung WK-2026-0001 · Muster GmbH', amountCents: 50000, sicherheit: 'erwartet' }]) + assert.equal(r.endeErwartetCents, 60893) + assert.equal(r.endeSicherCents, 10893) + // sicherer Verlauf bleibt bei 10893, erwarteter Verlauf steigt: kein tieferer Stand als der Start + assert.deepEqual(r.minSicher, { amountCents: 10893, date: TODAY }) + assert.deepEqual(r.minErwartet, { amountCents: 10893, date: TODAY }) + assert.equal(r.fixkosten30Cents, 0) +}) + +test('überfällige Rechnung: heute + 14 Tage mit Zusatz; Entwürfe, bezahlte und Rechnungen ohne Rest zählen nicht', () => { + const r = base({ + invoices: [ + invoice({ status: 'ueberfaellig', dueDate: '2026-09-20', remainingCents: 30000 }), + invoice({ id: 'in_2', status: 'entwurf', dueDate: '2026-10-10' }), + invoice({ id: 'in_3', status: 'bezahlt', dueDate: '2026-10-10' }), + invoice({ id: 'in_4', dueDate: '2026-10-10', remainingCents: 0 }), + invoice({ id: 'in_5', dueDate: null }), + ], + }) + assert.deepEqual(r.posten, [ + { date: '2026-10-15', label: 'Rechnung WK-2026-0001 · Muster GmbH (überfällig, angenommen)', amountCents: 30000, sicherheit: 'erwartet' }, + ]) + // überfällig, aber Horizont kürzer als 14 Tage: nichts + assert.deepEqual(base({ horizonDays: 10, invoices: [invoice({ status: 'ueberfaellig', dueDate: '2026-09-20' })] }).posten, []) +}) + +test('offene Rechnung mit Fälligkeit vor heute (Daten von gestern) wird wie überfällig behandelt, nicht verschluckt', () => { + const r = base({ invoices: [invoice({ dueDate: '2026-09-30' })] }) + assert.deepEqual(r.posten.map((p) => p.date), ['2026-10-15']) + assert.match(r.posten[0].label, /\(überfällig, angenommen\)$/) +}) + +test('Abo 2000 mit Periodenende 05.10.: Posten am 09.10., danach monatlich (09.11. bei langem Horizont)', () => { + const r = base({ subscriptions: [sub()] }) + assert.deepEqual(r.posten, [{ date: '2026-10-09', label: 'Abo-Verlängerung', amountCents: 2000, sicherheit: 'erwartet' }]) + const long = base({ horizonDays: 70, subscriptions: [sub()] }) + assert.deepEqual(dates(long), ['2026-10-09', '2026-11-09', '2026-12-09']) +}) + +test('Abo: Periodenende wird als Berliner Tag gelesen (Mitternachtsrand), Klammerung am Monatsende', () => { + // 30.10. 23:30 UTC ist in Berlin schon der 31.10. + const r = base({ horizonDays: 100, subscriptions: [sub({ currentPeriodEnd: '2026-10-30T23:30:00.000+00:00' })] }) + // Periodenenden 31.10., 30.11., 31.12. -> + 4 Tage + assert.deepEqual(dates(r), ['2026-11-04', '2026-12-04', '2027-01-04']) +}) + +test('Abo mit cancelAtPeriodEnd: kein Posten am 09.11.', () => { + const r = base({ horizonDays: 70, subscriptions: [sub({ cancelAtPeriodEnd: true })] }) + assert.ok(!dates(r).includes('2026-11-09')) + assert.deepEqual(dates(r), ['2026-10-09']) +}) + +test('Abo mit cancelAt: keine Termine ab cancelAt', () => { + // gekündigt zum 05.11.: Verlängerung am 05.10. noch, die am 05.11. nicht mehr + const r = base({ horizonDays: 70, subscriptions: [sub({ cancelAt: '2026-11-05T10:00:00.000+00:00', cancelAtPeriodEnd: true })] }) + assert.deepEqual(dates(r), ['2026-10-09']) + // gekündigt zum 20.12.: 05.10., 05.11. und 05.12. laufen noch + const r2 = base({ horizonDays: 90, subscriptions: [sub({ cancelAt: '2026-12-20T10:00:00.000+00:00' })] }) + assert.deepEqual(dates(r2), ['2026-10-09', '2026-11-09', '2026-12-09']) + // gekündigt zum Tag der Verlängerung selbst: keine Verlängerung + const r3 = base({ horizonDays: 30, subscriptions: [sub({ cancelAt: '2026-10-05T10:00:00.000+00:00' })] }) + assert.deepEqual(r3.posten, []) +}) + +test('Abo: nur aktive zählen, Periodenende in der Vergangenheit wird vorgespult', () => { + const r = base({ + subscriptions: [ + sub({ customerId: 'c2', status: 'past_due' }), + sub({ customerId: 'c3', status: 'canceled' }), + sub({ customerId: 'c4', status: 'trialing' }), + sub({ customerId: 'c5', currentPeriodEnd: '2026-09-20T10:00:00.000+00:00' }), + ], + }) + // 20.09. + 1 Monat = 20.10. -> Auszahlung 24.10. + assert.deepEqual(r.posten.map((p) => [p.date, p.amountCents]), [['2026-10-24', 2000]]) +}) + +test('ESt-Vorauszahlung 50000: 10.12. bei Horizont 90, keine bei Horizont 30; ohne Betrag keine', () => { + const settings = { estPrepaymentCents: 50000 } + const r90 = buildForecast({ today: TODAY, horizonDays: 90, balanceCents: BALANCE, settings }) + assert.deepEqual(r90.posten, [{ date: '2026-12-10', label: 'ESt-Vorauszahlung', amountCents: -50000, sicherheit: 'sicher' }]) + assert.equal(r90.fixkosten30Cents, 50000) + assert.deepEqual(r90.minSicher, { amountCents: -39107, date: '2026-12-10' }) + assert.deepEqual(buildForecast({ today: TODAY, horizonDays: 30, balanceCents: BALANCE, settings }).posten, []) + assert.deepEqual(buildForecast({ today: TODAY, horizonDays: 90, balanceCents: BALANCE, settings: { estPrepaymentCents: 0 } }).posten, []) +}) + +test('ESt-Vorauszahlung: alle vier Termine, auch über den Jahreswechsel', () => { + const r = buildForecast({ today: '2026-11-15', horizonDays: 400, balanceCents: 0, settings: { estPrepaymentCents: 100 } }) + assert.deepEqual(dates(r), ['2026-12-10', '2027-03-10', '2027-06-10', '2027-09-10', '2027-12-10']) + // Termin genau heute zählt + assert.deepEqual(dates(buildForecast({ today: '2026-12-10', horizonDays: 0, balanceCents: 0, settings: { estPrepaymentCents: 100 } })), ['2026-12-10']) +}) + +test('Auszahlung in_transit 1500 am 03.10.: sicher +1500; pending zählt, paid/canceled/failed und vergangene nicht', () => { + const payouts = [ + { id: 'po_1', amountCents: 1500, arrivalDate: '2026-10-03', status: 'in_transit' }, + { id: 'po_2', amountCents: 700, arrivalDate: '2026-10-06', status: 'pending' }, + { id: 'po_3', amountCents: 999, arrivalDate: '2026-10-04', status: 'paid' }, + { id: 'po_4', amountCents: 999, arrivalDate: '2026-10-04', status: 'canceled' }, + { id: 'po_5', amountCents: 999, arrivalDate: '2026-10-04', status: 'failed' }, + { id: 'po_6', amountCents: 999, arrivalDate: '2026-09-29', status: 'pending' }, + { id: 'po_7', amountCents: 999, arrivalDate: '2026-11-05', status: 'pending' }, + ] + const r = base({ payouts }) + assert.deepEqual(r.posten, [ + { date: '2026-10-03', label: 'Stripe-Auszahlung', amountCents: 1500, sicherheit: 'sicher' }, + { date: '2026-10-06', label: 'Stripe-Auszahlung', amountCents: 700, sicherheit: 'sicher' }, + ]) + assert.equal(r.endeSicherCents, 13093) + assert.equal(r.endeErwartetCents, 13093) + assert.equal(r.fixkosten30Cents, 0) +}) + +test('Sortierung: nach Datum, am selben Tag Abflüsse zuerst', () => { + const r = base({ + payouts: [{ id: 'po_1', amountCents: 1500, arrivalDate: '2026-10-14', status: 'in_transit' }], + invoices: [invoice()], + fixedCosts: [monthly({ nextDate: '2026-10-14' }), monthly({ $id: 'fk0', name: 'Frueh', nextDate: '2026-10-02', amountCents: 100 })], + }) + assert.deepEqual(r.posten.map((p) => [p.date, p.amountCents]), [ + ['2026-10-02', -100], + ['2026-10-14', -449], + ['2026-10-14', 1500], + ['2026-10-14', 50000], + ]) +}) + +test('Minima: je Linie getrennt, frühestes Datum bei Gleichstand, am selben Tag zählen Abflüsse vor Zuflüssen', () => { + // Kontostand 1000: Miete 800 am 05.10. (sicher) -> 200; Rechnung 5000 am 03.10. (erwartet) -> erwartet nie unter 1000 + const r = buildForecast({ + today: TODAY, balanceCents: 1000, settings: {}, + fixedCosts: [monthly({ name: 'Miete', amountCents: 800, nextDate: '2026-10-05' })], + invoices: [invoice({ dueDate: '2026-10-03', remainingCents: 5000 })], + }) + assert.deepEqual(r.minSicher, { amountCents: 200, date: '2026-10-05' }) + // erwartet: 1000 -> 6000 (03.10.) -> 5200 (05.10.); tiefster Stand ist der Start + assert.deepEqual(r.minErwartet, { amountCents: 1000, date: TODAY }) + assert.equal(r.endeSicherCents, 200) + assert.equal(r.endeErwartetCents, 5200) + + // Gleichstand: zwei Abflüsse, dazwischen ein Zufluss, der den Stand wieder auf das alte Minimum hebt + const tie = buildForecast({ + today: TODAY, balanceCents: 1000, settings: {}, + fixedCosts: [monthly({ name: 'A', amountCents: 500, nextDate: '2026-10-02' }), monthly({ $id: 'b', name: 'B', amountCents: 500, nextDate: '2026-10-04' })], + payouts: [{ id: 'po_1', amountCents: 500, arrivalDate: '2026-10-03', status: 'pending' }], + }) + assert.deepEqual(tie.minSicher, { amountCents: 500, date: '2026-10-02' }) + + // Abfluss und Zufluss am selben Tag: Abfluss zuerst, also entsteht die Spitze nach unten + const sameDay = buildForecast({ + today: TODAY, balanceCents: 100, settings: {}, + fixedCosts: [monthly({ amountCents: 300, nextDate: '2026-10-10' })], + payouts: [{ id: 'po_1', amountCents: 300, arrivalDate: '2026-10-10', status: 'pending' }], + }) + assert.deepEqual(sameDay.minSicher, { amountCents: -200, date: '2026-10-10' }) +}) + +test('sicher und erwartet gemischt: Erwartetes verändert die sichere Linie nicht', () => { + const r = base({ + fixedCosts: [monthly({ amountCents: 20000, nextDate: '2026-10-20' })], + invoices: [invoice({ dueDate: '2026-10-10', remainingCents: 30000 })], + subscriptions: [sub()], + }) + assert.deepEqual(r.minSicher, { amountCents: -9107, date: '2026-10-20' }) + assert.deepEqual(r.minErwartet, { amountCents: 10893, date: TODAY }) + assert.equal(r.endeSicherCents, -9107) + assert.equal(r.endeErwartetCents, 10893 - 20000 + 30000 + 2000) + assert.equal(r.fixkosten30Cents, 20000) +}) + +test('Ungültige Eingaben: Kontostand und Horizont müssen ganze Zahlen sein', () => { + assert.throws(() => buildForecast({ today: TODAY, balanceCents: 10.5 }), /balanceCents/) + assert.throws(() => buildForecast({ today: TODAY, balanceCents: null }), /balanceCents/) + assert.throws(() => buildForecast({ today: TODAY, balanceCents: 0, horizonDays: -1 }), /horizonDays/) + assert.throws(() => buildForecast({ today: TODAY, balanceCents: 0, horizonDays: 1.5 }), /horizonDays/) + assert.throws(() => buildForecast({ today: 'heute', balanceCents: 0 })) +}) + +test('Eingaben bleiben unverändert', () => { + const fixedCosts = [monthly()] + const invoices = [invoice()] + const subscriptions = [sub()] + const snapshot = JSON.stringify({ fixedCosts, invoices, subscriptions }) + base({ fixedCosts, invoices, subscriptions }) + assert.equal(JSON.stringify({ fixedCosts, invoices, subscriptions }), snapshot) +})