Wiederkehrende Zahlungen aufspueren
Am Anfang einer Betreuung weiss niemand, welche Vertraege es gibt. Die Antwort steht aber im Kontoauszug: Wer regelmaessig abbucht, hat einen Vertrag. Neu unter Vermoegen & Kasse wird aus hunderten Buchungszeilen eine Arbeitsliste. Aufbau: - Die Erkennung liegt als reine Funktionen in src/lib/finanzen/wiederkehrend.ts, ohne Datenbank. Nur so laesst sich jede Einordnung einzeln nachrechnen. - Der Datenzugriff steht getrennt in wiederkehrend-laden.ts. - Ein kleines Modell haelt fest, was bewusst KEIN Vertrag ist. Es haengt am normalisierten Empfaengernamen, nicht an Buchungen - nur so ueberlebt die Entscheidung den naechsten Import. Zwei Haltungen liegen der Erkennung zugrunde: Im Zweifel getrennt lassen. Zusammengefasst wird nur das Unstrittige: Gross- und Kleinschreibung, Satzzeichen, Umlaute, Rechtsform am Ende. Keine Aehnlichkeitssuche - "Stadtwerke Bamberg" und "Stadtwerke Bayreuth" trennen drei Zeichen und zwei Rechnungssteller. Zwei Zeilen zu viel sind harmlos; eine falsche Gruppe verbirgt einen Vertrag. Nichts behaupten, was nicht belegt ist. Statt "monatlich" steht dort "6 Zahlungen, etwa monatlich". Schwankt der Betrag, wird die Spanne genannt. Ohne erkennbaren Abstand wird nicht hochgerechnet, sondern gezeigt, was geflossen ist. Dazu eine zweite Liste fuer einzelne groessere Zahlungen: Die Gruppierung braucht zwei Zahlungen, ein Jahresbeitrag erscheint bei einem importierten Jahr aber nur einmal - und ausgerechnet Versicherungen zahlt man oft jaehrlich. Geprueft: 37 Einheitstests, die Haelfte davon in der Gegenrichtung, plus vier End-to-End-Tests auf beiden Geraeteprofilen. Der Browsertest hat zwei Fehler gefunden, die Typpruefung und Einheitstests durchgelassen haben - darunter ein verlorener Backslash, der die Vorbelegung des Beitrags stillschweigend verschluckte. Nebenbei: .claude/ ins .gitignore. Dort legen parallel arbeitende Agenten ihre Worktrees ab; ein "git add -A" haette einen kompletten zweiten Checkout eingecheckt. Refs #3
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
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
|
||||
}
|
||||
|
||||
/** 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<Rhythmus, string> = {
|
||||
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<Rhythmus, number> = {
|
||||
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[]
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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<string>
|
||||
/** Hinterlegte Vertraege, zum Abgleich. */
|
||||
vertraege?: VertragAbgleich[]
|
||||
}
|
||||
|
||||
/** Ordnet Vertraege ihrem normalisierten Gesellschaftsnamen zu. */
|
||||
function vertragsIndex(vertraege: VertragAbgleich[]): Map<string, VertragAbgleich> {
|
||||
const index = new Map<string, VertragAbgleich>()
|
||||
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<string>()
|
||||
const index = vertragsIndex(optionen.vertraege ?? [])
|
||||
|
||||
const nachSchluessel = new Map<string, ZahlungEingabe[]>()
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
// 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<string>()
|
||||
const index = vertragsIndex(optionen.vertraege ?? [])
|
||||
|
||||
const anzahlJeSchluessel = new Map<string, number>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
Reference in New Issue
Block a user