import { summe } from '@/lib/finanzen/betrag' import { nachTagSortieren, tageZwischen } from '@/lib/finanzen/datum' /** * Wiederkehrende Zahlungen aufspueren - ohne Datenbank, damit sich jede * Entscheidung einzeln nachrechnen laesst. * * Der Anlass: Am Anfang einer Betreuung weiss niemand, welche Vertraege es * gibt. Die Antwort steht aber im Kontoauszug - wer regelmaessig abbucht, * hat einen Vertrag. Diese Datei macht aus hunderten Buchungszeilen eine * Arbeitsliste. * * Zwei Haltungen liegen allem zugrunde: * * 1. **Im Zweifel getrennt lassen.** Zwei Zeilen zu viel sind harmlos - man * sieht hin und hakt ab. Eine falsch zusammengefasste Gruppe verbirgt * dagegen einen Vertrag, und der fehlt dann im Vermoegensverzeichnis. * Deshalb wird nur zusammengefasst, was sicher dasselbe ist. * * 2. **Nichts behaupten, was nicht belegt ist.** Die Erkennung nennt immer * mit, worauf sie sich stuetzt ("4 Zahlungen, etwa alle 3 Monate"). * Eine Einordnung ohne nachvollziehbare Grundlage waere geraten, und * geratene Zahlen landen sonst im Bericht ans Gericht. */ // ----------------------------------------------------------------------------- // Eingabe und Ergebnis // ----------------------------------------------------------------------------- export type ZahlungEingabe = { id: string datum: Date betragCent: bigint gegenstelle: string | null /** Haengt die Buchung schon an einem Vertrag? (Issue #11) */ vertragId?: string | null } /** Ein hinterlegter Vertrag - zum Abgleich, ob die Zahlung schon erklaert ist. */ export type VertragAbgleich = { id: string bezeichnung: string gesellschaft: string | null } export type Rhythmus = | 'MONATLICH' | 'ZWEIMONATLICH' | 'VIERTELJAEHRLICH' | 'HALBJAEHRLICH' | 'JAEHRLICH' | 'UNREGELMAESSIG' export const RHYTHMUS_BESCHRIFTUNG: Record = { MONATLICH: 'monatlich', ZWEIMONATLICH: 'zweimonatlich', VIERTELJAEHRLICH: 'vierteljährlich', HALBJAEHRLICH: 'halbjährlich', JAEHRLICH: 'jährlich', UNREGELMAESSIG: 'unregelmäßig', } /** Wie oft der Rhythmus im Jahr zuschlaegt. Fuer die Hochrechnung. */ const ZAHLUNGEN_JE_JAHR: Record = { MONATLICH: 12, ZWEIMONATLICH: 6, VIERTELJAEHRLICH: 4, HALBJAEHRLICH: 2, JAEHRLICH: 1, UNREGELMAESSIG: 0, } export type Zahlungsgruppe = { /** Normalisierter Name - der Schluessel, unter dem abgehakt wird. */ schluessel: string /** Die laengste vorgefundene Schreibweise, fuer die Anzeige. */ anzeigename: string /** Alle vorgefundenen Schreibweisen, falls mehrere zusammengefasst wurden. */ schreibweisen: string[] anzahl: number ersteZahlung: Date letzteZahlung: Date /** Median der Betraege - unempfindlicher gegen einen Ausreisser als der Schnitt. */ betragCent: bigint kleinsterBetragCent: bigint groessterBetragCent: bigint /** Schwanken die Betraege stark? Dann ist es eher keine feste Praemie. */ betragSchwankt: boolean rhythmus: Rhythmus /** Medianabstand in Tagen - die Grundlage der Rhythmuserkennung. */ abstandTage: number | null /** Hochgerechnet auf ein Jahr. Bei unregelmaessigem Rhythmus: das * tatsaechlich Gezahlte, nicht hochgerechnet. */ jahresbetragCent: bigint /** Passt der Name zu einem hinterlegten Vertrag? */ vertragId: string | null vertragBezeichnung: string | null buchungIds: string[] /** * Wie viele Buchungen der Gruppe haengen bereits an einem Vertrag? * Grundlage fuer die Sammelzuordnung: Nur der Rest muss noch verknuepft * werden. */ bereitsZugeordnet: number /** Buchungen der Gruppe ohne Vertragsbezug. */ offeneBuchungIds: string[] } // ----------------------------------------------------------------------------- // Namen vergleichbar machen // ----------------------------------------------------------------------------- /** * Rechtsformen und Zusaetze, die keinen Unterschied machen. * * Bewusst kurz gehalten. Jeder Eintrag hier ist eine Behauptung "das darf * weg" - und jede falsche Behauptung wirft zwei verschiedene Empfaenger * zusammen. "Versicherung" steht deshalb NICHT in der Liste: Zwischen * "Allianz Versicherung" und "Allianz Lebensversicherung" liegt ein * Unterschied, der im Vermoegensverzeichnis zaehlt. */ const RECHTSFORMEN = [ 'gmbh & co kg', 'gmbh und co kg', 'gmbh', 'mbh', 'ohg', 'kgaa', 'kg', 'ag', 'se', 'ev', 'vvag', 'ag', 'aoer', 'koer', ] /** Ersetzt Umlaute, damit "Muenchener" und "Münchener" zusammenfallen. */ function umlauteAufloesen(text: string): string { return text .replace(/ä/g, 'ae') .replace(/ö/g, 'oe') .replace(/ü/g, 'ue') .replace(/ß/g, 'ss') } /** * Macht zwei Schreibweisen desselben Empfaengers vergleichbar. * * Was hier zusammenfaellt, wird spaeter in einer Zeile angezeigt. Deshalb * nur das Unstrittige: Gross- und Kleinschreibung, Satzzeichen, doppelte * Leerzeichen, Umlaute und die Rechtsform am Ende. * * Was ausdruecklich NICHT passiert: Aehnlichkeitssuche ueber Abstandsmasse. * "Stadtwerke Bamberg" und "Stadtwerke Bayreuth" unterscheiden sich in drei * Zeichen und sind zwei voellig verschiedene Rechnungssteller. */ export function normalisieren(gegenstelle: string | null | undefined): string { if (!gegenstelle) return '' let text = umlauteAufloesen(gegenstelle.toLowerCase()) // Satzzeichen zu Leerzeichen, damit "vers.ag" zu "vers ag" wird und die // Rechtsform anschliessend als eigenes Wort erkennbar ist. text = text.replace(/[.,;:/\\()[\]{}"'`*+_-]/g, ' ') text = text.replace(/\s+/g, ' ').trim() // Rechtsform nur am ENDE entfernen. Mitten im Namen kann dieselbe // Zeichenfolge etwas anderes sein - "ag" steckt in "Agentur". let geaendert = true while (geaendert) { geaendert = false for (const form of RECHTSFORMEN) { if (text.endsWith(` ${form}`)) { text = text.slice(0, -(form.length + 1)).trim() geaendert = true break } } } return text } // ----------------------------------------------------------------------------- // Rhythmus // ----------------------------------------------------------------------------- function median(werte: number[]): number { if (werte.length === 0) return 0 const sortiert = [...werte].sort((a, b) => a - b) const mitte = Math.floor(sortiert.length / 2) return sortiert.length % 2 === 1 ? sortiert[mitte]! : Math.round((sortiert[mitte - 1]! + sortiert[mitte]!) / 2) } function medianBigInt(werte: bigint[]): bigint { if (werte.length === 0) return 0n const sortiert = [...werte].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) const mitte = Math.floor(sortiert.length / 2) return sortiert.length % 2 === 1 ? sortiert[mitte]! : (sortiert[mitte - 1]! + sortiert[mitte]!) / 2n } /** * Ordnet einen Medianabstand einem Rhythmus zu. * * Die Spannen sind bewusst weit: Lastschriften kommen am ersten Werktag, * und der wandert. Zwischen dem 1. Februar und dem 1. Maerz liegen 28 Tage, * zwischen dem 1. Juli und dem 1. August 31 - beides ist monatlich. */ export function rhythmusAusAbstand(tage: number): Rhythmus { if (tage >= 24 && tage <= 38) return 'MONATLICH' if (tage >= 50 && tage <= 70) return 'ZWEIMONATLICH' if (tage >= 80 && tage <= 100) return 'VIERTELJAEHRLICH' if (tage >= 160 && tage <= 200) return 'HALBJAEHRLICH' if (tage >= 330 && tage <= 400) return 'JAEHRLICH' return 'UNREGELMAESSIG' } /** Der Medianabstand zwischen aufeinanderfolgenden Zahlungen, in Tagen. */ export function abstandInTagen(daten: Date[]): number | null { if (daten.length < 2) return null const sortiert = nachTagSortieren(daten, (d) => d) const abstaende: number[] = [] for (let i = 1; i < sortiert.length; i++) { abstaende.push(Math.abs(tageZwischen(sortiert[i - 1]!, sortiert[i]!))) } return median(abstaende) } /** * Schwanken die Betraege so stark, dass es keine feste Zahlung mehr ist? * * Ein Fuenftel Abweichung vom Median ist grosszuegig - Beitragsanpassungen * und Stromabschlaege bewegen sich in dieser Groessenordnung. Wer darueber * liegt, zahlt eher nach Verbrauch als nach Vertrag. */ export function betraegeSchwanken(betraege: bigint[]): boolean { if (betraege.length < 2) return false const mitte = medianBigInt(betraege) if (mitte === 0n) return true return betraege.some((betrag) => { const abweichung = betrag > mitte ? betrag - mitte : mitte - betrag // Ganzzahlig gerechnet: |x - m| * 100 > m * 20 return abweichung * 100n > mitte * 20n }) } // ----------------------------------------------------------------------------- // Gruppieren // ----------------------------------------------------------------------------- /** * Ab wann gilt eine Zahlung als wiederkehrend? * * Zwei ist das Minimum, das ueberhaupt einen Abstand ergibt. Damit bleibt * eine Luecke, die man kennen muss: Eine JAEHRLICH abgebuchte Praemie * erscheint bei nur einem importierten Jahr genau einmal und wird hier * nicht gefunden. Deshalb gibt es daneben die Liste der einzelnen groesseren * Zahlungen - siehe einzelneZahlungen(). */ export const MINDESTANZAHL = 2 export type GruppierungsOptionen = { /** Schon abgehakte Schluessel - "das ist kein Vertrag". */ abgehakt?: ReadonlySet /** Hinterlegte Vertraege, zum Abgleich. */ vertraege?: VertragAbgleich[] } /** Ordnet Vertraege ihrem normalisierten Gesellschaftsnamen zu. */ function vertragsIndex(vertraege: VertragAbgleich[]): Map { const index = new Map() for (const vertrag of vertraege) { const schluessel = normalisieren(vertrag.gesellschaft) // Erster Treffer gewinnt. Zwei Vertraege bei derselben Gesellschaft sind // moeglich (Hausrat und Haftpflicht bei der Allianz); fuer die Frage // "ist das schon erklaert?" genuegt einer. if (schluessel && !index.has(schluessel)) index.set(schluessel, vertrag) } return index } /** * Waehlt aus mehreren Schreibweisen die, die man jemandem zeigen kann. * * Banken liefern denselben Empfaenger mal als "Allianz Lebensversicherung * AG", mal als "ALLIANZ LEBENSVERS AG". Die laengste zu nehmen waere * naheliegend - und faellt regelmaessig auf die geschriene Fassung herein, * weil Versalien nicht kuerzer sind. * * Das ist mehr als Kosmetik: Dieser Name wird beim "Vertrag anlegen" in das * Feld Gesellschaft uebernommen. Steht dort alles in Grossbuchstaben, tippt * man es von Hand neu - und dann war die Vorbelegung wertlos. * * Also: erst die Schreibweisen mit Klein- und Grossbuchstaben, unter denen * die laengste. Gibt es nur geschriene, nimmt man eben die laengste davon. */ export function besteSchreibweise(schreibweisen: string[]): string { if (schreibweisen.length === 0) return '' const gemischt = schreibweisen.filter((name) => name !== name.toUpperCase()) const auswahl = gemischt.length > 0 ? gemischt : schreibweisen return auswahl.reduce((a, b) => (b.length > a.length ? b : a), auswahl[0]!) } /** * Fasst Zahlungen zu Gruppen zusammen und bewertet sie. * * Erwartet werden ausschliesslich Ausgaben, die die betreute Person * betreffen - die Auswahl trifft der Aufrufer, weil sie von der Datenbank * kommt. Umbuchungen zwischen eigenen Konten gehoeren nicht hierher: Wer * monatlich aufs Sparbuch legt, hat deswegen keinen Vertrag. */ export function gruppieren( zahlungen: ZahlungEingabe[], optionen: GruppierungsOptionen = {}, ): Zahlungsgruppe[] { const abgehakt = optionen.abgehakt ?? new Set() const index = vertragsIndex(optionen.vertraege ?? []) const nachSchluessel = new Map() for (const zahlung of zahlungen) { const schluessel = normalisieren(zahlung.gegenstelle) // Ohne Gegenstelle gibt es nichts zu gruppieren. Solche Buchungen sind // Bargeldabhebungen oder handerfasste Zeilen - kein Vertragshinweis. if (!schluessel) continue if (abgehakt.has(schluessel)) continue const bisher = nachSchluessel.get(schluessel) if (bisher) bisher.push(zahlung) else nachSchluessel.set(schluessel, [zahlung]) } const gruppen: Zahlungsgruppe[] = [] for (const [schluessel, posten] of nachSchluessel) { if (posten.length < MINDESTANZAHL) continue const sortiert = nachTagSortieren(posten, (p) => p.datum) const betraege = posten.map((p) => p.betragCent) const abstandTage = abstandInTagen(posten.map((p) => p.datum)) const rhythmus = abstandTage === null ? 'UNREGELMAESSIG' : rhythmusAusAbstand(abstandTage) const betragCent = medianBigInt(betraege) const jeJahr = ZAHLUNGEN_JE_JAHR[rhythmus] const jahresbetragCent = jeJahr > 0 ? betragCent * BigInt(jeJahr) : summe(betraege) const schreibweisen = [ ...new Set(posten.map((p) => (p.gegenstelle ?? '').trim()).filter(Boolean)), ].sort() const vertrag = index.get(schluessel) ?? null gruppen.push({ schluessel, anzeigename: besteSchreibweise(schreibweisen) || schluessel, schreibweisen, anzahl: posten.length, ersteZahlung: sortiert[0]!.datum, letzteZahlung: sortiert[sortiert.length - 1]!.datum, betragCent, kleinsterBetragCent: betraege.reduce((a, b) => (b < a ? b : a), betraege[0]!), groessterBetragCent: betraege.reduce((a, b) => (b > a ? b : a), betraege[0]!), betragSchwankt: betraegeSchwanken(betraege), rhythmus, abstandTage, jahresbetragCent, vertragId: vertrag?.id ?? null, vertragBezeichnung: vertrag?.bezeichnung ?? null, buchungIds: sortiert.map((p) => p.id), bereitsZugeordnet: posten.filter((p) => Boolean(p.vertragId)).length, offeneBuchungIds: sortiert.filter((p) => !p.vertragId).map((p) => p.id), }) } // Absteigend nach Jahresbetrag: Was am meisten kostet, will man zuerst // geklaert haben. return gruppen.sort((a, b) => a.jahresbetragCent > b.jahresbetragCent ? -1 : a.jahresbetragCent < b.jahresbetragCent ? 1 : a.anzeigename.localeCompare(b.anzeigename, 'de'), ) } // ----------------------------------------------------------------------------- // Die Luecke: einmalige Zahlungen // ----------------------------------------------------------------------------- /** * Ab welchem Betrag eine einzelne Zahlung erwaehnenswert ist. * * 150 EUR ist die Grenze, ab der sich das Nachsehen lohnt. Darunter stehen * Einkaeufe und Gebuehren, darueber faengt es an, nach Jahresbeitrag * auszusehen. */ export const EINZELZAHLUNG_AB_CENT = 15_000n export type Einzelzahlung = { schluessel: string anzeigename: string buchungId: string datum: Date betragCent: bigint vertragId: string | null vertragBezeichnung: string | null } /** * Einzelne groessere Zahlungen ohne hinterlegten Vertrag. * * Der Grund fuer diese zweite Liste: Die Gruppierung braucht mindestens * zwei Zahlungen. Eine jaehrlich abgebuchte Versicherungspraemie erscheint * bei einem importierten Jahr aber genau einmal - und ausgerechnet * Versicherungen werden oft jaehrlich gezahlt. Ohne diese Liste waere die * Suche genau dort blind, wo sie am meisten bringen soll. * * Sie ist ausdruecklich kein Vertragsverdacht, sondern eine Merkliste. In * der Oberflaeche steht sie deshalb getrennt und mit eigener Ueberschrift. */ export function einzelneZahlungen( zahlungen: ZahlungEingabe[], optionen: GruppierungsOptionen = {}, ): Einzelzahlung[] { const abgehakt = optionen.abgehakt ?? new Set() const index = vertragsIndex(optionen.vertraege ?? []) const anzahlJeSchluessel = new Map() for (const zahlung of zahlungen) { const schluessel = normalisieren(zahlung.gegenstelle) if (!schluessel) continue anzahlJeSchluessel.set(schluessel, (anzahlJeSchluessel.get(schluessel) ?? 0) + 1) } const treffer: Einzelzahlung[] = [] for (const zahlung of zahlungen) { const schluessel = normalisieren(zahlung.gegenstelle) if (!schluessel) continue if (abgehakt.has(schluessel)) continue // Alles, was mehrfach vorkommt, steht bereits in der Gruppenliste. if ((anzahlJeSchluessel.get(schluessel) ?? 0) >= MINDESTANZAHL) continue if (zahlung.betragCent < EINZELZAHLUNG_AB_CENT) continue const vertrag = index.get(schluessel) ?? null treffer.push({ schluessel, anzeigename: (zahlung.gegenstelle ?? '').trim() || schluessel, buchungId: zahlung.id, datum: zahlung.datum, betragCent: zahlung.betragCent, vertragId: vertrag?.id ?? null, vertragBezeichnung: vertrag?.bezeichnung ?? null, }) } return treffer.sort((a, b) => a.betragCent > b.betragCent ? -1 : a.betragCent < b.betragCent ? 1 : 0, ) } // ----------------------------------------------------------------------------- // Fuer die Anzeige // ----------------------------------------------------------------------------- /** * Der Satz, der unter einer Gruppe steht. * * Er nennt die Grundlage der Einordnung, statt nur das Ergebnis zu * behaupten - damit man widersprechen kann, wenn es nicht stimmt. */ export function rhythmusErklaerung(gruppe: Zahlungsgruppe): string { const zahlungen = `${gruppe.anzahl} ${gruppe.anzahl === 1 ? 'Zahlung' : 'Zahlungen'}` if (gruppe.rhythmus === 'UNREGELMAESSIG' || gruppe.abstandTage === null) { return `${zahlungen}, ohne erkennbaren Abstand` } const tage = gruppe.abstandTage const monate = Math.round(tage / 30) const abstand = monate <= 1 ? 'etwa monatlich' : `etwa alle ${monate} Monate` return `${zahlungen}, ${abstand}` } /** * Uebersetzt den erkannten Rhythmus in den Zahlrhythmus des Vertrags. * * Zwei Faelle haben dort keine Entsprechung: ZWEIMONATLICH kennt das * Vertragsmodell nicht, und UNREGELMAESSIG ist keine Aussage, sondern das * Eingestaendnis, keine zu haben. Beide liefern null - dann bleibt im * Formular die Vorgabe stehen, statt etwas Falsches vorzublenden. Eine * vorbelegte falsche Angabe wird uebersehen; ein leeres Feld nicht. */ export function alsZahlRhythmus(rhythmus: Rhythmus): string | null { switch (rhythmus) { case 'MONATLICH': return 'MONATLICH' case 'VIERTELJAEHRLICH': return 'VIERTELJAEHRLICH' case 'HALBJAEHRLICH': return 'HALBJAEHRLICH' case 'JAEHRLICH': return 'JAEHRLICH' default: return null } } /** * Sucht den Vertrag, zu dem eine Gegenstelle EINDEUTIG passt. * * Gedacht fuer die automatische Zuordnung beim Import. Die Betonung liegt * auf eindeutig: Passen zwei Vertraege (Hausrat und Haftpflicht bei * derselben Gesellschaft), wird nichts zugeordnet. Eine falsche Zuordnung * waere schlimmer als gar keine - sie behauptet einen Beleg, den es nicht * gibt, und niemand sieht noch einmal hin. * * Gibt null zurueck, wenn nichts oder mehr als eines passt. */ export function eindeutigerVertrag( gegenstelle: string | null | undefined, vertraege: VertragAbgleich[], ): VertragAbgleich | null { const schluessel = normalisieren(gegenstelle) if (!schluessel) return null const treffer = vertraege.filter((vertrag) => normalisieren(vertrag.gesellschaft) === schluessel) return treffer.length === 1 ? treffer[0]! : null } /** Wie viele Gruppen warten noch auf eine Entscheidung? Fuer das Dashboard. */ export function offeneKandidaten(gruppen: Zahlungsgruppe[]): number { return gruppen.filter((gruppe) => gruppe.vertragId === null).length }