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 // Kuendigung: Ein gesetztes cancelAt gilt allein (es kann mehrere Perioden voraus liegen, z.B. Mindestlaufzeit); // cancelAtPeriodEnd allein heisst: Stripe bucht zum Periodenende nichts mehr ab, also gar keine Verlaengerung. const cancelAt = berlinDayOf(subscription.cancelAt) if (!cancelAt && subscription.cancelAtPeriodEnd) continue for (const renewal of recurring(periodEnd, 1)) { const payoutDate = addDays(renewal, PAYOUT_DELAY_DAYS) if (payoutDate > end) break if (cancelAt && renewal >= cancelAt) break // nur Verlaengerungen vor dem Kuendigungstag 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, } }