import type { BuchungKategorie, BuchungRichtung, ZahlRhythmus } from '@prisma/client' import { summe, teileGerundet } from '@/lib/finanzen/betrag' import { alsTag, imZeitraum, nachTagSortieren } from '@/lib/finanzen/datum' import { AUSGABE_KATEGORIEN, EINNAHME_KATEGORIEN, KATEGORIE_BESCHRIFTUNG, } from '@/lib/finanzen/kategorien' /** * Auswertung des Kassenbuchs - ohne Datenbank, damit sich jede Zahl * einzeln nachrechnen laesst. * * Drei Regeln liegen allem zugrunde: * * 1. Umbuchungen zwischen eigenen Konten sind weder Einnahme noch * Ausgabe. Wer 500 EUR vom Giro aufs Sparbuch legt, hat nichts * eingenommen. Sie tauchen in keiner Summe auf. * 2. Buchungen mit betrifftBetreute = false gehoeren nicht zum Vermoegen * der betreuten Person. Das sind eigene Auslagen des Betreuers - sie * werden getrennt gefuehrt und getrennt abgerechnet. * 3. Vermoegenswirksame Buchungen (Verkaufserloes, Rueckkaufswert) sind * keine laufenden Einnahmen. Sie veraendern aber sehr wohl den * Kontostand und muessen deshalb in der Rechenprobe mitlaufen - * sonst geht die Probe nicht auf. Im Bericht stehen sie eigens * ausgewiesen. */ export type BuchungEingabe = { id: string datum: Date betragCent: bigint richtung: BuchungRichtung kategorie: BuchungKategorie gegenstelle: string | null verwendungszweck: string | null kontoId: string | null kontoBezeichnung?: string | null belegId: string | null belegTitel?: string | null belegFehltBegruendung: string | null /** Dauerzahlung zu einem Vertrag - dann ist der Vertrag der Beleg. */ vertragId?: string | null vertragBezeichnung?: string | null vertragsnummer?: string | null /** Hinterlegter Beitrag samt Zahlweise - Grundlage der Betragspruefung. */ vertragBeitragCent?: bigint | null vertragRhythmus?: ZahlRhythmus | null /** Wurde die Zuordnung beim Import automatisch gesetzt? */ vertragAutomatisch?: boolean betrifftBetreute: boolean vermoegenswirksam: boolean /** Laeuft die Buchung ueber ein Konto des Betreuers? */ aufBetreuerkonto?: boolean } /** Zaehlt nur, was das Vermoegen der betreuten Person beruehrt. */ export function istVermoegensbuchung(buchung: BuchungEingabe): boolean { return buchung.betrifftBetreute && buchung.richtung !== 'UMBUCHUNG' } // ----------------------------------------------------------------------------- // Belegzwang // ----------------------------------------------------------------------------- /** * Wie weit darf die Zahlung vom hinterlegten Beitrag abweichen? * * Ein Fuenftel. Das ist grosszuegig und mit Absicht: Beitragsanpassungen, * Versicherungsteuer und ein nachtraeglich geaenderter Selbstbehalt bewegen * sich in dieser Groessenordnung, und bei jeder davon soll die Anwendung * nicht meckern. * * Was sie fangen soll, ist die andere Groessenordnung: die Praemie, die von * 42 auf 420 Euro springt, oder die Lastschrift, die versehentlich zweimal * lief. Wer die Grenze enger zieht, bekommt taeglich Warnungen und sieht * die eine wichtige nicht mehr. */ export const BETRAGS_TOLERANZ_PROZENT = 20n /** * Passt der Betrag zu dem, was am Vertrag steht? * * Verglichen wird gegen den hinterlegten Beitrag, unveraendert. Kein * Umrechnen auf andere Zahlweisen. * * Der erste Entwurf tat genau das - Jahresbetrag bilden und gegen * jaehrlich, halbjaehrlich, vierteljaehrlich und monatlich pruefen, damit * es auch passt, wenn die Bank anders abbucht als der Vertrag es fuehrt. * Das Ergebnis war ein Toleranzband mit vier Loechern: Bei einem * Monatsbeitrag von 42,50 EUR liegen 420 EUR innerhalb von 20 Prozent des * Jahresbetrags von 510 EUR. Ausgerechnet der Fall, vor dem diese Pruefung * schuetzen soll, waere durchgerutscht. * * Bucht die Bank tatsaechlich jaehrlich ab, waehrend am Vertrag "monatlich" * steht, dann stimmen die Vertragsdaten nicht - und dann soll die * Buchung als "zu pruefen" auffallen, statt stillschweigend akzeptiert zu * werden. Die Zahlweise am Vertrag zu berichtigen dauert zehn Sekunden und * macht die Akte richtiger. * * Ohne hinterlegten Beitrag gibt es nichts zu vergleichen - dann lautet die * Antwort "unbekannt" (null), nicht "in Ordnung". Der Unterschied zaehlt: * Es soll nicht so aussehen, als waere geprueft worden. */ export function betragPasstZumVertrag( betragCent: bigint, beitragCent: bigint | null | undefined, rhythmus: ZahlRhythmus | null | undefined, ): boolean | null { if (beitragCent === null || beitragCent === undefined || beitragCent <= 0n) return null // Zu einer Einmalzahlung gibt es keine wiederkehrende Rate, gegen die // sich vergleichen liesse. if (!rhythmus || rhythmus === 'EINMALIG') return null const betrag = betragCent < 0n ? -betragCent : betragCent const abweichung = betrag > beitragCent ? betrag - beitragCent : beitragCent - betrag return abweichung * 100n <= beitragCent * BETRAGS_TOLERANZ_PROZENT } /** * Ist die Buchung durch einen Vertrag belegt? * * Bei einem Dauerschuldverhaeltnis ist der Vertrag der Beleg - fuer zwoelf * gleiche Lastschriften gibt es keine zwoelf Quittungen, sondern eine * Police. * * Die Anerkennung faellt weg, sobald der Betrag deutlich abweicht. Sonst * waere die Zuordnung eine Hintertuer, durch die auch eine verdreifachte * Praemie unbemerkt als "belegt" durchginge. */ export function belegDurchVertrag(buchung: BuchungEingabe): boolean { if (buchung.belegId) return false if (!buchung.vertragId) return false return ( betragPasstZumVertrag( buchung.betragCent, buchung.vertragBeitragCent, buchung.vertragRhythmus, ) !== false ) } /** * Zugeordnet, aber der Betrag passt nicht - das will jemand ansehen. * * Kein Fehler und kein Vorwurf: Meistens ist es eine Beitragsanpassung, die * am Vertrag noch nicht nachgetragen wurde. Genau deshalb steht sie hier * und nicht wortlos unter "Beleg fehlt". */ export function betragPruefen(buchung: BuchungEingabe): boolean { if (buchung.belegId) return false if (!buchung.vertragId) return false return ( betragPasstZumVertrag( buchung.betragCent, buchung.vertragBeitragCent, buchung.vertragRhythmus, ) === false ) } /** * Fehlt der Beleg? Eine Begruendung heilt das Fehlen fuer die Statistik, * nicht fuer den Aktenordner - aber sie beantwortet die Frage des * Rechtspflegers "und wo ist der Beleg?" schon vorab. */ export function belegFehlt(buchung: BuchungEingabe): boolean { if (buchung.belegId) return false if (belegDurchVertrag(buchung)) return false return !(buchung.belegFehltBegruendung ?? '').trim() } export function belegBegruendet(buchung: BuchungEingabe): boolean { if (buchung.belegId) return false if (belegDurchVertrag(buchung)) return false return (buchung.belegFehltBegruendung ?? '').trim() !== '' } export type Belegquote = { gesamt: number mitBeleg: number /** * Dauerzahlung zu einem Vertrag - eigene Kategorie, NICHT eingerechnet in * "begruendet". Ein Vertrag belegt, eine Begruendung erklaert nur; das * zusammenzuwerfen nimmt der Zahl ihre Aussage gegenueber dem * Rechtspfleger. */ durchVertrag: number begruendet: number ohneNachweis: number /** Zugeordnet, aber der Betrag weicht ab. Teilmenge von ohneNachweis. */ zuPruefen: number /** 0 bis 100, ganzzahlig. Bei null Buchungen: 100 - es fehlt ja nichts. */ quoteProzent: number } export function belegquote(buchungen: BuchungEingabe[]): Belegquote { const gesamt = buchungen.length const mitBeleg = buchungen.filter((b) => Boolean(b.belegId)).length const durchVertrag = buchungen.filter((b) => belegDurchVertrag(b)).length const begruendet = buchungen.filter((b) => belegBegruendet(b)).length const ohneNachweis = buchungen.filter((b) => belegFehlt(b)).length const zuPruefen = buchungen.filter((b) => betragPruefen(b)).length const quoteProzent = gesamt === 0 ? 100 : Math.round(((mitBeleg + durchVertrag + begruendet) / gesamt) * 100) return { gesamt, mitBeleg, durchVertrag, begruendet, ohneNachweis, zuPruefen, quoteProzent } } /** * Der Fehler, der in der Rechnungslegung um die Ohren fliegt: eine * Buchung, die auf einem Konto des Betreuers laeuft und trotzdem als * Vermoegen der betreuten Person gefuehrt wird. Das vermischt zwei * Vermoegensmassen - genau das, was § 1835 BGB verhindern soll. */ export function vermischungsWarnung(buchung: { aufBetreuerkonto?: boolean betrifftBetreute: boolean }): string | null { if (buchung.aufBetreuerkonto && buchung.betrifftBetreute) { return ( 'Diese Buchung läuft über ein Konto, das als Konto des Betreuers gekennzeichnet ist, ' + 'gilt aber als Vermögen der betreuten Person. Beides zusammen vermischt zwei ' + 'Vermögensmassen. Entweder ist das Konto falsch gekennzeichnet, oder die Buchung ist ' + 'eine eigene Auslage – dann bitte „betrifft Vermögen der Betreuten“ abwählen.' ) } return null } // ----------------------------------------------------------------------------- // Gruppierung nach Kategorien // ----------------------------------------------------------------------------- export type KategorieGruppe = { kategorie: BuchungKategorie beschriftung: string summeCent: bigint davonVermoegenswirksamCent: bigint anzahl: number buchungen: BuchungEingabe[] } function gruppieren( buchungen: BuchungEingabe[], reihenfolge: BuchungKategorie[], ): KategorieGruppe[] { const nachKategorie = new Map() for (const buchung of buchungen) { const liste = nachKategorie.get(buchung.kategorie) ?? [] liste.push(buchung) nachKategorie.set(buchung.kategorie, liste) } // Erst die Kategorien in Berichtsreihenfolge, danach alles, was sonst // noch auftaucht (z.B. eine Rueckerstattung in einer Ausgabekategorie). const bekannt = reihenfolge.filter((k) => nachKategorie.has(k)) const uebrig = [...nachKategorie.keys()].filter((k) => !reihenfolge.includes(k)) return [...bekannt, ...uebrig].map((kategorie) => { const liste = nachTagSortieren(nachKategorie.get(kategorie) ?? [], (b) => b.datum) return { kategorie, beschriftung: KATEGORIE_BESCHRIFTUNG[kategorie], summeCent: summe(liste.map((b) => b.betragCent)), davonVermoegenswirksamCent: summe( liste.filter((b) => b.vermoegenswirksam).map((b) => b.betragCent), ), anzahl: liste.length, buchungen: liste, } }) } export function einnahmenGruppiert(buchungen: BuchungEingabe[]): KategorieGruppe[] { return gruppieren( buchungen.filter((b) => istVermoegensbuchung(b) && b.richtung === 'EINNAHME'), EINNAHME_KATEGORIEN, ) } export function ausgabenGruppiert(buchungen: BuchungEingabe[]): KategorieGruppe[] { return gruppieren( buchungen.filter((b) => istVermoegensbuchung(b) && b.richtung === 'AUSGABE'), AUSGABE_KATEGORIEN, ) } // ----------------------------------------------------------------------------- // Laufende Einnahmen und Ausgaben (fuer das Vermoegensverzeichnis) // ----------------------------------------------------------------------------- export type LaufenderPosten = { kategorie: BuchungKategorie beschriftung: string /** Summe im betrachteten Zeitraum. */ summeCent: bigint /** Auf den Monat heruntergerechnet, kaufmaennisch auf den Cent gerundet. */ monatlichCent: bigint anzahl: number } export type LaufendeBewegungen = { von: Date bis: Date monate: number einnahmen: LaufenderPosten[] ausgaben: LaufenderPosten[] summeEinnahmenCent: bigint summeAusgabenCent: bigint monatlichEinnahmenCent: bigint monatlichAusgabenCent: bigint } /** * Was laeuft regelmaessig? Das Vermoegensverzeichnis verlangt neben dem * Bestand auch die laufenden Einnahmen und Ausgaben. Grundlage sind die * Buchungen des angegebenen Zeitraums (in der Regel die letzten zwoelf * Monate), heruntergerechnet auf den Monat. * * Vermoegenswirksame Buchungen bleiben hier aussen vor: ein einmaliger * Verkaufserloes ist keine laufende Einnahme und wuerde die Hochrechnung * verfaelschen. */ export function laufendeBewegungen( buchungen: BuchungEingabe[], von: Date, bis: Date, monate = 12, ): LaufendeBewegungen { const imFenster = buchungen.filter( (b) => istVermoegensbuchung(b) && !b.vermoegenswirksam && imZeitraum(b.datum, von, bis), ) const umrechnen = (gruppen: KategorieGruppe[]): LaufenderPosten[] => gruppen.map((g) => ({ kategorie: g.kategorie, beschriftung: g.beschriftung, summeCent: g.summeCent, monatlichCent: teileGerundet(g.summeCent, BigInt(monate)), anzahl: g.anzahl, })) const einnahmen = umrechnen(einnahmenGruppiert(imFenster)) const ausgaben = umrechnen(ausgabenGruppiert(imFenster)) const summeEinnahmenCent = summe(einnahmen.map((p) => p.summeCent)) const summeAusgabenCent = summe(ausgaben.map((p) => p.summeCent)) return { von: alsTag(von), bis: alsTag(bis), monate, einnahmen, ausgaben, summeEinnahmenCent, summeAusgabenCent, // Bewusst aus der Gesamtsumme gerechnet und nicht aus den gerundeten // Einzelwerten - sonst weicht die Summe der Zeilen vom Gesamtwert ab. monatlichEinnahmenCent: teileGerundet(summeEinnahmenCent, BigInt(monate)), monatlichAusgabenCent: teileGerundet(summeAusgabenCent, BigInt(monate)), } } // ----------------------------------------------------------------------------- // Kennzahlen fuer die Uebersichtsseite // ----------------------------------------------------------------------------- export type Kassenkennzahlen = { anzahl: number einnahmenCent: bigint ausgabenCent: bigint saldoCent: bigint vermoegenswirksamCent: bigint eigeneAuslagenCent: bigint beleg: Belegquote vermischungen: number } export function kennzahlen(buchungen: BuchungEingabe[]): Kassenkennzahlen { const relevant = buchungen.filter(istVermoegensbuchung) const einnahmenCent = summe( relevant.filter((b) => b.richtung === 'EINNAHME').map((b) => b.betragCent), ) const ausgabenCent = summe( relevant.filter((b) => b.richtung === 'AUSGABE').map((b) => b.betragCent), ) return { anzahl: buchungen.length, einnahmenCent, ausgabenCent, saldoCent: einnahmenCent - ausgabenCent, vermoegenswirksamCent: summe( relevant.filter((b) => b.vermoegenswirksam).map((b) => b.betragCent), ), eigeneAuslagenCent: summe( buchungen.filter((b) => !b.betrifftBetreute).map((b) => b.betragCent), ), beleg: belegquote(buchungen), vermischungen: buchungen.filter((b) => vermischungsWarnung(b) !== null).length, } }