Files
ClaudeandClaude Sonnet 5.5 039fdcaf27 Finanzen: Vorschau, Steuer und Aufgaben genauer (Fixkosten ohne ESt, verspaetete Auszahlungen, Abos nicht ladbar)
M6: fixkosten30Cents zaehlt nur Fixkosten, die ESt-Vorauszahlung bleibt sicherer Posten.
M7: verspaetete Auszahlung (Ankunftstag vorbei, noch nicht bezahlt) kommt heute.
M8: ohne Stripe sind Kleinunternehmer-Zaehler und Anteil unbekannt (null), keine Warnung daraus.
M12: Verbindungsdokument ohne Sitzung zaehlt als keine Verbindung.
M13: Buchungen privat/fixkosten gehen nicht in die Fixkosten-Vorschlaege.
M14: Vorjahresumsatz ueber 25.000 EUR ist eine hohe Aufgabe.
M15: scheitert das Laden der Abos, meldet abos.fehler statt die ganze Uebersicht zu kippen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00

198 lines
8.7 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 (mit Ankunftstag vor heute: heute), 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.
* fixkosten30Cents ist die Summe der Fixkosten im Zeitraum (ohne ESt-Vorauszahlungen).
*/
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) continue
// Eine noch nicht angekommene Auszahlung mit Ankunftstag vor heute (Stripe ist verspaetet) kommt frueh, nicht nie: heute
const date = arrival < today ? today : arrival
if (inRange(date)) add(date, 'Stripe-Auszahlung', payout.amountCents, SICHER)
}
// Nur die Fixkosten zaehlen fuer fixkosten30Cents: die ESt-Vorauszahlung steckt schon in der Steuerruecklage
let fixkosten30Cents = 0
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)
fixkosten30Cents += cost.amountCents
}
}
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)
return {
posten,
minSicher: sicher.min,
minErwartet: erwartet.min,
endeSicherCents: sicher.end,
endeErwartetCents: erwartet.end,
fixkosten30Cents,
annahme: FORECAST_ANNAHME,
}
}