Files
betreuungs-cockpit/src/lib/finanzen/wiederkehrend.ts
T
Florian Wilde 18709a8e49 Ein Jahr Kontobewegungen im Beispielfall
Der Demofall brachte Konten und Kontostaende mit, aber keine einzige
Buchung. Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung
wiederkehrender Zahlungen liefen darin also leer - wer die Anwendung zum
ersten Mal ansah, musste erst eine CSV importieren.

Neu sind 107 Buchungen ueber zwoelf Monate, erzeugt aus einem Muster
statt einzeln abgetippt: Rente, Betriebsrente und Pflegekasse als
Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag, Telefon und
Kontofuehrung monatlich, dazu Rundfunkbeitrag und Grundsteuer
vierteljaehrlich sowie eine Handvoll Einzelposten.

Was der Fall damit zeigt:
- Das Heimentgelt steht in drei Schreibweisen auf dem Auszug und wird
  trotzdem als eine monatliche Zahlung gefuehrt.
- Genau eine Buchung hat weder Quittung noch Begruendung. Eine, nicht
  zwanzig: Die Liste soll etwas zu zeigen haben und nicht rot leuchten.
- Die Sterbegeld-Lastschriften bleiben unzugeordnet, damit sich der
  Handgriff "zwoelf Zahlungen als belegt zuordnen" ausprobieren laesst.
  Der Beitrag steigt im Mai von 18,40 auf 19,80 - damit auch die
  Abweichungspruefung sichtbar wird.
- Der Kontostand zum 31.07. wird aus Anfangsstand und Buchungen
  gerechnet, nicht behauptet. Ein Beispielfall, der sich selbst
  widerspricht, waere schlimmer als einer ohne Buchungen.

Zwei Funde beim Bauen:

Der Unique-Index auf (kontoId, importReferenz) hat einen echten Fehler in
den Daten gefangen: Zwei Renten am Monatsersten ergaben dieselbe
Referenz. Genau dafuer ist er da.

"gGmbH" fehlte in der Rechtsformliste. Dadurch galten "Seniorenzentrum
St. Katharina" und "... gGmbH" als zwei Empfaenger - und die Gruppe
zerfiel ausgerechnet in dem Fachgebiet, in dem fast jeder Traeger eine
gGmbH ist. Ergaenzt, mit Test.

Geprueft: ein Einheitstest fuer die Rechtsform, vier End-to-End-Tests am
Beispielfall - Umfang und Zeitraum, Kontostand gegen Buchungssumme, die
erkannten Gruppen und das Kassenbuch.

Refs #22
2026-08-23 21:35:46 +02:00

551 lines
19 KiB
TypeScript

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<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[]
/**
* 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',
// Die haeufigste Rechtsform in diesem Fachgebiet ueberhaupt: Heime,
// Sozialdienste und Traeger sind fast durchweg gGmbH. Fehlte sie,
// zerfiele die Zusammenfassung genau dort, wo sie gebraucht wird.
'ggmbh',
'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),
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<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
}
}
/**
* 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
}