Files
Webklar-Kundenbereich/test/financeForecast.test.js
T

301 lines
18 KiB
JavaScript

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 ohne cancelAt: gar keine Verlängerung (Stripe bucht zum Periodenende nichts mehr ab)', () => {
const r = base({ horizonDays: 70, subscriptions: [sub({ cancelAtPeriodEnd: true, cancelAt: null })] })
assert.deepEqual(r.posten, [])
// gilt auch ohne das Feld cancelAt
const { cancelAt, ...ohneCancelAt } = sub({ cancelAtPeriodEnd: true })
assert.deepEqual(base({ horizonDays: 70, subscriptions: [ohneCancelAt] }).posten, [])
// andere Abos daneben laufen weiter
const mixed = base({ horizonDays: 40, subscriptions: [sub({ cancelAtPeriodEnd: true }), sub({ customerId: 'c2' })] })
assert.deepEqual(mixed.posten.map((p) => [p.date, p.amountCents]), [['2026-10-09', 2000], ['2026-11-09', 2000]])
})
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 mit cancelAt mehrere Perioden voraus (Mindestlaufzeit): Verlängerungen bis vor cancelAt, cancelAtPeriodEnd ändert daran nichts', () => {
// Produktionsform: Stripe liefert cancelAtPeriodEnd = true, sobald cancel_at gesetzt ist
const cancelled = (cancelAt, extra = {}) => sub({ cancelAt, cancelAtPeriodEnd: true, ...extra })
// cancelAt 01.01.2027, Horizont 120 (bis 29.01.2027): 05.10., 05.11., 05.12. -> Auszahlung 09.10., 09.11., 09.12.; die vom 05.01. nicht
const r = base({ horizonDays: 120, subscriptions: [cancelled('2027-01-01T10:00:00.000+00:00')] })
assert.deepEqual(dates(r), ['2026-10-09', '2026-11-09', '2026-12-09'])
assert.ok(r.posten.every((p) => p.amountCents === 2000 && p.sicherheit === 'erwartet'))
// cancelAt am Verlängerungstag selbst: diese Verlängerung entfällt, die davor bleiben
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [cancelled('2027-01-05T10:00:00.000+00:00')] })), ['2026-10-09', '2026-11-09', '2026-12-09'])
// einen Tag später: die Verlängerung am 05.01. läuft noch (Auszahlung 09.01.)
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [cancelled('2027-01-06T10:00:00.000+00:00')] })), ['2026-10-09', '2026-11-09', '2026-12-09', '2027-01-09'])
// cancelAt wird als Berliner Tag gelesen: 04.01. 23:30 UTC ist in Berlin bereits der 05.01.
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [cancelled('2027-01-04T23:30:00.000+00:00')] })), ['2026-10-09', '2026-11-09', '2026-12-09'])
// ohne cancelAtPeriodEnd (nur cancelAt) dasselbe
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [sub({ cancelAt: '2027-01-01T10:00:00.000+00:00' })] })), ['2026-10-09', '2026-11-09', '2026-12-09'])
})
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)
})