188 lines
8.2 KiB
JavaScript
188 lines
8.2 KiB
JavaScript
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,
|
|
}
|
|
}
|