feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht
Damit ist Etappe 1 fachlich vollstaendig. Ersteinrichtung in drei Schritten, bewusst ausserhalb des Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert. Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf. Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich sonst dauerhaft aussperren. Erst danach gibt es die Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash. Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts gerechnet statt ueber eine Differenz, damit Schaltjahre und der 29. Februar von selbst stimmen. Daran haengen Jahresbericht, Rechnungslegung und die Ausschlussfrist der Aufwandspauschale. Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch dann speicherbar bleiben, wenn das Fristenmodul fehlt. Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu. Die Aenderungen liegen verschluesselt, weil dort Diagnosen und Kontostaende stehen koennen. Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang. Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026, leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk mit acht Nachforderungen. Nur ausserhalb der Produktion. Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Geldbetraege.
|
||||
*
|
||||
* In dieser Anwendung gibt es nur eine Darstellung von Geld: BigInt in
|
||||
* Cent. Kein Float, kein Decimal, keine Zwischenrechnung in Euro. Der
|
||||
* Grund steht im Schema: die Rechnungslegung nach § 1865 BGB darf keine
|
||||
* Rundungsfehler erben, und beim Gericht wird auf den Cent geprueft.
|
||||
*
|
||||
* Hier stehen deshalb nur zwei Sorten von Funktionen: solche, die Text
|
||||
* in Cent verwandeln (Formular, CSV-Import), und solche, die auf Cent
|
||||
* rechnen, ohne dass ein Cent verloren geht.
|
||||
*/
|
||||
|
||||
export class BetragFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht)
|
||||
this.name = 'BetragFehler'
|
||||
}
|
||||
}
|
||||
|
||||
const NICHT_ZIFFER_ODER_TRENNER = /[^0-9.,+-]/g
|
||||
|
||||
/**
|
||||
* Liest einen Betrag aus Text und liefert Cent.
|
||||
*
|
||||
* Erkannt werden die Schreibweisen, die in deutschen Kontoauszuegen und
|
||||
* CSV-Ausleitungen tatsaechlich vorkommen:
|
||||
*
|
||||
* "1.234,56" "1234,56" "-1.234,56" "1.234,56-" "1234,5"
|
||||
* "1234" "€ 1.234,56" "1 234,56" "(1.234,56)"
|
||||
* "1234.56" (englische Schreibweise, z.B. aus CAMT oder Excel)
|
||||
*
|
||||
* Regeln bei mehrdeutigen Trennzeichen:
|
||||
*
|
||||
* - Kommen Punkt UND Komma vor, ist das zuletzt stehende Zeichen das
|
||||
* Dezimaltrennzeichen, das andere das Tausendertrennzeichen.
|
||||
* - Ein alleinstehendes Komma ist immer Dezimaltrennzeichen (deutsch).
|
||||
* - Ein alleinstehender Punkt ist Tausendertrennzeichen, wenn genau drei
|
||||
* Ziffern folgen und Ziffern davorstehen ("1.234"), sonst
|
||||
* Dezimaltrennzeichen ("1234.56"). Diese Anwendung liest deutsche
|
||||
* Dateien; "1.234" heisst hier eintausendzweihundertvierunddreissig.
|
||||
*
|
||||
* Mehr als zwei Nachkommastellen sind ein Fehler und keine Rundung -
|
||||
* wer aus 1,005 EUR eigenmaechtig 1,01 EUR macht, verschiebt Geld.
|
||||
*/
|
||||
export function betragParsen(eingabe: string | null | undefined): bigint {
|
||||
if (eingabe === null || eingabe === undefined) {
|
||||
throw new BetragFehler('Bitte einen Betrag angeben.')
|
||||
}
|
||||
|
||||
let text = eingabe.trim()
|
||||
if (text === '') throw new BetragFehler('Bitte einen Betrag angeben.')
|
||||
|
||||
// Buchhalterische Klammer: (12,34) bedeutet minus 12,34.
|
||||
let negativ = false
|
||||
if (/^\(.*\)$/.test(text)) {
|
||||
negativ = true
|
||||
text = text.slice(1, -1)
|
||||
}
|
||||
|
||||
text = text.replace(NICHT_ZIFFER_ODER_TRENNER, '')
|
||||
|
||||
// Vorzeichen darf vorne oder hinten stehen ("1.234,56-" ist in
|
||||
// Bankausleitungen ueblich).
|
||||
if (text.startsWith('-')) {
|
||||
negativ = !negativ
|
||||
text = text.slice(1)
|
||||
} else if (text.startsWith('+')) {
|
||||
text = text.slice(1)
|
||||
}
|
||||
if (text.endsWith('-')) {
|
||||
negativ = !negativ
|
||||
text = text.slice(0, -1)
|
||||
} else if (text.endsWith('+')) {
|
||||
text = text.slice(0, -1)
|
||||
}
|
||||
|
||||
if (text === '' || /[+-]/.test(text)) {
|
||||
throw new BetragFehler(`"${eingabe}" ist kein Betrag. Beispiel: 1.234,56`)
|
||||
}
|
||||
|
||||
const letztesKomma = text.lastIndexOf(',')
|
||||
const letzterPunkt = text.lastIndexOf('.')
|
||||
|
||||
let dezimaltrenner: '.' | ',' | null = null
|
||||
if (letztesKomma >= 0 && letzterPunkt >= 0) {
|
||||
dezimaltrenner = letztesKomma > letzterPunkt ? ',' : '.'
|
||||
} else if (letztesKomma >= 0) {
|
||||
dezimaltrenner = ','
|
||||
} else if (letzterPunkt >= 0) {
|
||||
const nachkomma = text.length - letzterPunkt - 1
|
||||
const punkteAnzahl = text.split('.').length - 1
|
||||
const tausender = punkteAnzahl > 1 || (nachkomma === 3 && letzterPunkt > 0)
|
||||
dezimaltrenner = tausender ? null : '.'
|
||||
}
|
||||
|
||||
let ganz: string
|
||||
let bruch: string
|
||||
if (dezimaltrenner === null) {
|
||||
ganz = text.replace(/[.,]/g, '')
|
||||
bruch = ''
|
||||
} else {
|
||||
const teiler = dezimaltrenner === ',' ? letztesKomma : letzterPunkt
|
||||
ganz = text.slice(0, teiler).replace(/[.,]/g, '')
|
||||
bruch = text.slice(teiler + 1).replace(/[.,]/g, '')
|
||||
}
|
||||
|
||||
if (!/^\d*$/.test(ganz) || !/^\d*$/.test(bruch) || ganz + bruch === '') {
|
||||
throw new BetragFehler(`"${eingabe}" ist kein Betrag. Beispiel: 1.234,56`)
|
||||
}
|
||||
if (bruch.length > 2) {
|
||||
throw new BetragFehler(
|
||||
`"${eingabe}" hat mehr als zwei Nachkommastellen. Beträge werden auf den Cent geführt.`,
|
||||
)
|
||||
}
|
||||
|
||||
const cent = BigInt(ganz === '' ? '0' : ganz) * 100n + BigInt(bruch.padEnd(2, '0') || '0')
|
||||
return negativ ? -cent : cent
|
||||
}
|
||||
|
||||
/** Wie betragParsen, gibt aber null statt eines Fehlers zurueck. */
|
||||
export function betragParsenWennMoeglich(eingabe: string | null | undefined): bigint | null {
|
||||
try {
|
||||
return betragParsen(eingabe)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cent als deutsche Zahl ohne Waehrungszeichen ("1.234,56").
|
||||
* Fuer Eingabefelder gedacht - zum Anzeigen gibt es formatCent().
|
||||
*/
|
||||
export function betragFormatieren(cent: bigint): string {
|
||||
const negativ = cent < 0n
|
||||
const betrag = negativ ? -cent : cent
|
||||
const ganz = betrag / 100n
|
||||
const rest = betrag % 100n
|
||||
const ganzText = ganz
|
||||
.toString()
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
|
||||
return `${negativ ? '-' : ''}${ganzText},${rest.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Summe, ohne dass jemals ein Zwischenwert in Number landet. */
|
||||
export function summe(werte: Iterable<bigint>): bigint {
|
||||
let ergebnis = 0n
|
||||
for (const wert of werte) ergebnis += wert
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/**
|
||||
* Ganzzahlige Division mit kaufmaennischer Rundung (halbe Cent vom
|
||||
* Nullpunkt weg). Wird fuer Durchschnitte gebraucht - etwa "laufende
|
||||
* Einnahmen je Monat" im Vermoegensverzeichnis.
|
||||
*/
|
||||
export function teileGerundet(zaehler: bigint, nenner: bigint): bigint {
|
||||
if (nenner === 0n) throw new BetragFehler('Division durch null.')
|
||||
const negativ = zaehler < 0n !== nenner < 0n
|
||||
const a = zaehler < 0n ? -zaehler : zaehler
|
||||
const b = nenner < 0n ? -nenner : nenner
|
||||
const ganz = a / b
|
||||
const rest = a % b
|
||||
const ergebnis = rest * 2n >= b ? ganz + 1n : ganz
|
||||
return negativ ? -ergebnis : ergebnis
|
||||
}
|
||||
|
||||
/**
|
||||
* Verteilt einen Betrag auf n Teile, ohne dass ein Cent verschwindet:
|
||||
* die Restcents werden auf die vorderen Teile verteilt. Die Summe der
|
||||
* Rueckgabe ist immer exakt der Eingabebetrag.
|
||||
*/
|
||||
export function centAufteilen(gesamtCent: bigint, teile: number): bigint[] {
|
||||
if (!Number.isInteger(teile) || teile <= 0) {
|
||||
throw new BetragFehler('Ein Betrag lässt sich nur auf mindestens einen Teil aufteilen.')
|
||||
}
|
||||
const n = BigInt(teile)
|
||||
const negativ = gesamtCent < 0n
|
||||
const betrag = negativ ? -gesamtCent : gesamtCent
|
||||
const grundanteil = betrag / n
|
||||
const rest = betrag % n
|
||||
|
||||
const ergebnis: bigint[] = []
|
||||
for (let i = 0; i < teile; i++) {
|
||||
const anteil = grundanteil + (BigInt(i) < rest ? 1n : 0n)
|
||||
ergebnis.push(negativ ? -anteil : anteil)
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { alsTag, heute, jahrePlus, monatePlus, tagePlus, vorOderGleich } from '@/lib/finanzen/datum'
|
||||
|
||||
/**
|
||||
* Das Betreuungsjahr.
|
||||
*
|
||||
* Es laeuft ab dem Tag der Bestellung, nicht ab dem 1. Januar. Wer am
|
||||
* 15.03.2025 bestellt wurde, legt fuer den Zeitraum 15.03.2025 bis
|
||||
* 14.03.2026 Rechnung (§ 1865 Abs. 1 BGB), und die Aufwandspauschale
|
||||
* nach § 1878 BGB verfaellt sechs Monate nach dessen Ende.
|
||||
*
|
||||
* Das ist der haeufigste Rechenfehler in selbstgebauten Tabellen: man
|
||||
* rechnet das Kalenderjahr ab und wundert sich, warum das Gericht
|
||||
* nachfragt. Deshalb steht die Rechnung hier an genau einer Stelle.
|
||||
*
|
||||
* Schaltjahrfall: Ist die Bestellung am 29.02. erfolgt, gibt es in
|
||||
* Nicht-Schaltjahren keinen 29.02. Das Jahr endet dann am 27.02. und das
|
||||
* folgende beginnt am 28.02. - so entsteht weder eine Luecke noch eine
|
||||
* Ueberschneidung, und jeder Tag gehoert zu genau einem Betreuungsjahr.
|
||||
*/
|
||||
|
||||
export type Betreuungsjahr = {
|
||||
/** 1 = das erste Jahr ab Bestellung. */
|
||||
nummer: number
|
||||
/** Erster Tag, einschliesslich. */
|
||||
von: Date
|
||||
/** Letzter Tag, einschliesslich. */
|
||||
bis: Date
|
||||
/** "1. Betreuungsjahr (15.03.2025 – 14.03.2026)" - fuer Auswahllisten. */
|
||||
bezeichnung: string
|
||||
}
|
||||
|
||||
function formatKurz(datum: Date): string {
|
||||
const tag = String(datum.getUTCDate()).padStart(2, '0')
|
||||
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
|
||||
return `${tag}.${monat}.${datum.getUTCFullYear()}`
|
||||
}
|
||||
|
||||
function bauen(nummer: number, von: Date, bis: Date): Betreuungsjahr {
|
||||
return {
|
||||
nummer,
|
||||
von,
|
||||
bis,
|
||||
bezeichnung: `${nummer}. Betreuungsjahr (${formatKurz(von)} – ${formatKurz(bis)})`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Das n-te Betreuungsjahr. nummer ist 1-basiert; 1 ist das Jahr, das am
|
||||
* Tag der Bestellung beginnt.
|
||||
*/
|
||||
export function betreuungsjahr(bestellungAm: Date, nummer: number): Betreuungsjahr {
|
||||
if (!Number.isInteger(nummer) || nummer < 1) {
|
||||
throw new Error('Das Betreuungsjahr wird ab 1 gezählt.')
|
||||
}
|
||||
const start = alsTag(bestellungAm)
|
||||
const von = jahrePlus(start, nummer - 1)
|
||||
const bis = tagePlus(jahrePlus(start, nummer), -1)
|
||||
return bauen(nummer, von, bis)
|
||||
}
|
||||
|
||||
/**
|
||||
* In welches Betreuungsjahr faellt ein Datum? Null, wenn es vor der
|
||||
* Bestellung liegt - dafuer gibt es keine Rechnungslegung.
|
||||
*/
|
||||
export function betreuungsjahrZuDatum(bestellungAm: Date, datum: Date): Betreuungsjahr | null {
|
||||
const start = alsTag(bestellungAm)
|
||||
const ziel = alsTag(datum)
|
||||
if (ziel.getTime() < start.getTime()) return null
|
||||
|
||||
// Grobe Schaetzung ueber die Jahresdifferenz, danach hoechstens zwei
|
||||
// Korrekturschritte - das haelt auch den Schaltjahrfall aus.
|
||||
let nummer = ziel.getUTCFullYear() - start.getUTCFullYear() + 1
|
||||
if (nummer < 1) nummer = 1
|
||||
|
||||
for (let versuch = 0; versuch < 4; versuch++) {
|
||||
const jahr = betreuungsjahr(start, nummer)
|
||||
if (ziel.getTime() < jahr.von.getTime()) {
|
||||
nummer -= 1
|
||||
if (nummer < 1) return null
|
||||
continue
|
||||
}
|
||||
if (ziel.getTime() > jahr.bis.getTime()) {
|
||||
nummer += 1
|
||||
continue
|
||||
}
|
||||
return jahr
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Das Betreuungsjahr, in dem wir heute stehen. */
|
||||
export function laufendesBetreuungsjahr(
|
||||
bestellungAm: Date,
|
||||
stichtag: Date = heute(),
|
||||
): Betreuungsjahr | null {
|
||||
return betreuungsjahrZuDatum(bestellungAm, stichtag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Betreuungsjahre von der Bestellung bis einschliesslich des Jahres,
|
||||
* in dem "bis" liegt. Fuer die Auswahlliste im Berichtsformular - das
|
||||
* laufende Jahr steht mit dabei, denn eine Zwischenaufstellung ist
|
||||
* jederzeit erlaubt.
|
||||
*/
|
||||
export function betreuungsjahre(bestellungAm: Date, bis: Date = heute()): Betreuungsjahr[] {
|
||||
const letztes = betreuungsjahrZuDatum(bestellungAm, bis)
|
||||
if (!letztes) return []
|
||||
const liste: Betreuungsjahr[] = []
|
||||
for (let nummer = 1; nummer <= letztes.nummer; nummer++) {
|
||||
liste.push(betreuungsjahr(bestellungAm, nummer))
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist das Betreuungsjahr abgeschlossen? Nur dann ist die Rechnungslegung
|
||||
* faellig; vorher ist der Bericht eine Zwischenaufstellung.
|
||||
*/
|
||||
export function jahrAbgeschlossen(jahr: Betreuungsjahr, stichtag: Date = heute()): boolean {
|
||||
return vorOderGleich(jahr.bis, stichtag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ende der Ausschlussfrist fuer die Aufwandspauschale nach § 1878 BGB:
|
||||
* sechs Monate nach dem Ende des Betreuungsjahres. Danach ist der
|
||||
* Anspruch weg - kein Ermessen, keine Nachsicht.
|
||||
*/
|
||||
export function aufwandspauschaleBis(jahr: Betreuungsjahr): Date {
|
||||
return monatePlus(jahr.bis, 6)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { BuchungRichtung } from '@prisma/client'
|
||||
import { ImportFehler, importReferenzBilden, type ImportZeile } from '@/lib/finanzen/import'
|
||||
|
||||
/**
|
||||
* CAMT.053 (Kontoauszug im ISO-20022-Format).
|
||||
*
|
||||
* Fast jede Bank bietet den elektronischen Kontoauszug neben CSV auch
|
||||
* als CAMT-XML an. Der Vorteil: Betrag, Waehrung, Richtung und Datum
|
||||
* sind dort eindeutig ausgezeichnet - es gibt kein "ist der Punkt jetzt
|
||||
* ein Tausendertrennzeichen?" mehr. Deshalb ist dieser Weg der genauere.
|
||||
*
|
||||
* Gelesen wird bewusst nur ein kleiner, klar umrissener Ausschnitt:
|
||||
* die <Ntry>-Elemente eines <Stmt>. Alles andere - Zwischensalden,
|
||||
* Gebuehrendetails, strukturierte Referenzen - wird uebergangen.
|
||||
*
|
||||
* Der XML-Leser unten ist absichtlich klein gehalten und kennt keine
|
||||
* Entity-Deklarationen und keine externen Referenzen. Das ist keine
|
||||
* Bequemlichkeit, sondern Absicht: ein XML-Parser mit Entity-Aufloesung
|
||||
* ist die klassische Stelle fuer XXE-Angriffe, und eine Kontoauszugs-
|
||||
* datei kommt hier aus einem Download, den niemand geprueft hat.
|
||||
*/
|
||||
|
||||
export type XmlKnoten = {
|
||||
name: string
|
||||
attribute: Record<string, string>
|
||||
kinder: XmlKnoten[]
|
||||
text: string
|
||||
}
|
||||
|
||||
const ENTITAETEN: Record<string, string> = {
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
}
|
||||
|
||||
function entitaetenAufloesen(text: string): string {
|
||||
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (treffer, name: string) => {
|
||||
if (name.startsWith('#x') || name.startsWith('#X')) {
|
||||
const code = Number.parseInt(name.slice(2), 16)
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : treffer
|
||||
}
|
||||
if (name.startsWith('#')) {
|
||||
const code = Number.parseInt(name.slice(1), 10)
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : treffer
|
||||
}
|
||||
return ENTITAETEN[name] ?? treffer
|
||||
})
|
||||
}
|
||||
|
||||
/** Namensraumpraefix abschneiden: "ns:Ntry" wird zu "Ntry". */
|
||||
function ortsname(name: string): string {
|
||||
const doppelpunkt = name.indexOf(':')
|
||||
return doppelpunkt >= 0 ? name.slice(doppelpunkt + 1) : name
|
||||
}
|
||||
|
||||
function attributeLesen(roh: string): Record<string, string> {
|
||||
const attribute: Record<string, string> = {}
|
||||
const muster = /([A-Za-z_][\w.:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g
|
||||
let treffer: RegExpExecArray | null
|
||||
while ((treffer = muster.exec(roh)) !== null) {
|
||||
attribute[ortsname(treffer[1]!)] = entitaetenAufloesen(treffer[3] ?? treffer[4] ?? '')
|
||||
}
|
||||
return attribute
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimaler XML-Leser. Kennt Elemente, Attribute, Text und CDATA.
|
||||
* Prolog, Kommentare und Verarbeitungsanweisungen werden uebersprungen,
|
||||
* Doctype-Deklarationen ausdruecklich abgelehnt.
|
||||
*/
|
||||
export function xmlLesen(quelle: string): XmlKnoten {
|
||||
const text = quelle.charCodeAt(0) === 0xfeff ? quelle.slice(1) : quelle
|
||||
|
||||
if (/<!DOCTYPE/i.test(text)) {
|
||||
throw new ImportFehler(
|
||||
'Die Datei enthält eine DOCTYPE-Deklaration. Solche Dateien werden aus Sicherheitsgründen ' +
|
||||
'nicht gelesen. Bitte den Kontoauszug erneut ohne Zusätze herunterladen.',
|
||||
)
|
||||
}
|
||||
|
||||
const wurzel: XmlKnoten = { name: '#dokument', attribute: {}, kinder: [], text: '' }
|
||||
const stapel: XmlKnoten[] = [wurzel]
|
||||
let i = 0
|
||||
|
||||
while (i < text.length) {
|
||||
const start = text.indexOf('<', i)
|
||||
if (start < 0) break
|
||||
|
||||
if (start > i) {
|
||||
const inhalt = entitaetenAufloesen(text.slice(i, start))
|
||||
const aktuell = stapel[stapel.length - 1]!
|
||||
if (inhalt.trim() !== '') aktuell.text += inhalt
|
||||
}
|
||||
|
||||
if (text.startsWith('<!--', start)) {
|
||||
const ende = text.indexOf('-->', start)
|
||||
if (ende < 0) break
|
||||
i = ende + 3
|
||||
continue
|
||||
}
|
||||
|
||||
if (text.startsWith('<![CDATA[', start)) {
|
||||
const ende = text.indexOf(']]>', start)
|
||||
if (ende < 0) break
|
||||
stapel[stapel.length - 1]!.text += text.slice(start + 9, ende)
|
||||
i = ende + 3
|
||||
continue
|
||||
}
|
||||
|
||||
if (text.startsWith('<?', start)) {
|
||||
const ende = text.indexOf('?>', start)
|
||||
if (ende < 0) break
|
||||
i = ende + 2
|
||||
continue
|
||||
}
|
||||
|
||||
const ende = text.indexOf('>', start)
|
||||
if (ende < 0) break
|
||||
const roh = text.slice(start + 1, ende)
|
||||
i = ende + 1
|
||||
|
||||
if (roh.startsWith('/')) {
|
||||
if (stapel.length > 1) stapel.pop()
|
||||
continue
|
||||
}
|
||||
|
||||
const selbstschliessend = roh.endsWith('/')
|
||||
const koerper = selbstschliessend ? roh.slice(0, -1) : roh
|
||||
const leerzeichen = koerper.search(/\s/)
|
||||
const name = ortsname(leerzeichen < 0 ? koerper : koerper.slice(0, leerzeichen))
|
||||
const attribute = leerzeichen < 0 ? {} : attributeLesen(koerper.slice(leerzeichen))
|
||||
|
||||
const knoten: XmlKnoten = { name, attribute, kinder: [], text: '' }
|
||||
stapel[stapel.length - 1]!.kinder.push(knoten)
|
||||
if (!selbstschliessend) stapel.push(knoten)
|
||||
}
|
||||
|
||||
return wurzel
|
||||
}
|
||||
|
||||
/** Erstes Kind mit diesem Namen, beliebig tief. */
|
||||
export function knotenSuchen(knoten: XmlKnoten, name: string): XmlKnoten | null {
|
||||
for (const kind of knoten.kinder) {
|
||||
if (kind.name === name) return kind
|
||||
const tiefer = knotenSuchen(kind, name)
|
||||
if (tiefer) return tiefer
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Alle Knoten mit diesem Namen, beliebig tief. */
|
||||
export function alleKnoten(knoten: XmlKnoten, name: string): XmlKnoten[] {
|
||||
const gefunden: XmlKnoten[] = []
|
||||
for (const kind of knoten.kinder) {
|
||||
if (kind.name === name) gefunden.push(kind)
|
||||
gefunden.push(...alleKnoten(kind, name))
|
||||
}
|
||||
return gefunden
|
||||
}
|
||||
|
||||
function textVon(knoten: XmlKnoten | null): string | null {
|
||||
if (!knoten) return null
|
||||
const wert = knoten.text.replace(/\s+/g, ' ').trim()
|
||||
return wert === '' ? null : wert
|
||||
}
|
||||
|
||||
/**
|
||||
* Betraege in ISO-20022 sind eindeutig: Punkt als Dezimaltrennzeichen,
|
||||
* kein Tausendertrennzeichen, hoechstens fuenf Nachkommastellen. Fuer
|
||||
* Euro erwarten wir zwei - mehr waere ein Bruchteil eines Cents und
|
||||
* gehoert nicht in ein Kassenbuch.
|
||||
*/
|
||||
export function dezimalZuCent(text: string): bigint {
|
||||
const bereinigt = text.trim()
|
||||
const treffer = /^(-?)(\d+)(?:\.(\d+))?$/.exec(bereinigt)
|
||||
if (!treffer) {
|
||||
throw new ImportFehler(`"${text}" ist kein gültiger Betrag im CAMT-Format.`)
|
||||
}
|
||||
const bruch = treffer[3] ?? ''
|
||||
if (bruch.replace(/0+$/, '').length > 2) {
|
||||
throw new ImportFehler(
|
||||
`"${text}" hat mehr als zwei Nachkommastellen. Bruchteile eines Cents sind nicht buchbar.`,
|
||||
)
|
||||
}
|
||||
const cent = BigInt(treffer[2]!) * 100n + BigInt(bruch.padEnd(2, '0').slice(0, 2) || '0')
|
||||
return treffer[1] === '-' ? -cent : cent
|
||||
}
|
||||
|
||||
export type CamtErgebnis = {
|
||||
/** IBAN des Kontos aus dem Auszug - hilft beim Zuordnen. */
|
||||
iban: string | null
|
||||
/** Kontowaehrung, sofern angegeben. */
|
||||
waehrung: string | null
|
||||
zeilen: ImportZeile[]
|
||||
fehler: { zeilennummer: number; meldung: string; rohzeile: string[] }[]
|
||||
fremdwaehrung: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Umsaetze aus einer CAMT.053-Datei.
|
||||
*
|
||||
* Die Zeilennummer ist hier die laufende Nummer des <Ntry>-Elements -
|
||||
* eine Zeilennummer im Sinne einer Textdatei gibt es bei XML nicht.
|
||||
*/
|
||||
export function camtZuBuchungen(inhalt: Buffer | Uint8Array | string): CamtErgebnis {
|
||||
const quelle =
|
||||
typeof inhalt === 'string'
|
||||
? inhalt
|
||||
: Buffer.isBuffer(inhalt)
|
||||
? inhalt.toString('utf8')
|
||||
: Buffer.from(inhalt).toString('utf8')
|
||||
|
||||
const dokument = xmlLesen(quelle)
|
||||
const auszug = knotenSuchen(dokument, 'Stmt')
|
||||
if (!auszug) {
|
||||
throw new ImportFehler(
|
||||
'In der Datei ist kein Kontoauszug (<Stmt>) zu finden. Erwartet wird CAMT.053.',
|
||||
)
|
||||
}
|
||||
|
||||
const konto = knotenSuchen(auszug, 'Acct')
|
||||
const iban = konto ? textVon(knotenSuchen(konto, 'IBAN')) : null
|
||||
const waehrung = konto ? textVon(knotenSuchen(konto, 'Ccy')) : null
|
||||
|
||||
const zeilen: ImportZeile[] = []
|
||||
const fehler: CamtErgebnis['fehler'] = []
|
||||
let fremdwaehrung = 0
|
||||
const schluesselZaehler = new Map<string, number>()
|
||||
|
||||
const eintraege = alleKnoten(auszug, 'Ntry')
|
||||
eintraege.forEach((eintrag, i) => {
|
||||
const nummer = i + 1
|
||||
|
||||
const betragKnoten = eintrag.kinder.find((k) => k.name === 'Amt') ?? knotenSuchen(eintrag, 'Amt')
|
||||
const betragText = textVon(betragKnoten)
|
||||
if (!betragText) {
|
||||
fehler.push({ zeilennummer: nummer, meldung: 'Im Umsatz fehlt der Betrag.', rohzeile: [] })
|
||||
return
|
||||
}
|
||||
|
||||
const ccy = betragKnoten?.attribute['Ccy'] ?? waehrung
|
||||
if (ccy && ccy.toUpperCase() !== 'EUR') {
|
||||
fremdwaehrung++
|
||||
fehler.push({
|
||||
zeilennummer: nummer,
|
||||
meldung: `Währung "${ccy}" wird nicht übernommen. Diese Anwendung rechnet in Euro.`,
|
||||
rohzeile: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let betragCent: bigint
|
||||
try {
|
||||
betragCent = dezimalZuCent(betragText)
|
||||
} catch (ausnahme) {
|
||||
fehler.push({
|
||||
zeilennummer: nummer,
|
||||
meldung: ausnahme instanceof Error ? ausnahme.message : 'Der Betrag ist nicht lesbar.',
|
||||
rohzeile: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
if (betragCent < 0n) betragCent = -betragCent
|
||||
if (betragCent === 0n) {
|
||||
fehler.push({
|
||||
zeilennummer: nummer,
|
||||
meldung: 'Der Betrag ist 0,00 € – der Umsatz wurde übersprungen.',
|
||||
rohzeile: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const kennzeichen = textVon(
|
||||
eintrag.kinder.find((k) => k.name === 'CdtDbtInd') ?? knotenSuchen(eintrag, 'CdtDbtInd'),
|
||||
)
|
||||
if (kennzeichen !== 'CRDT' && kennzeichen !== 'DBIT') {
|
||||
fehler.push({
|
||||
zeilennummer: nummer,
|
||||
meldung: 'Im Umsatz fehlt die Angabe, ob es eine Gutschrift oder eine Belastung ist.',
|
||||
rohzeile: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
const richtung: BuchungRichtung = kennzeichen === 'CRDT' ? 'EINNAHME' : 'AUSGABE'
|
||||
|
||||
const buchungsdatum = datumAusKnoten(eintrag, 'BookgDt')
|
||||
const valuta = datumAusKnoten(eintrag, 'ValDt')
|
||||
const datum = buchungsdatum ?? valuta
|
||||
if (!datum) {
|
||||
fehler.push({ zeilennummer: nummer, meldung: 'Im Umsatz fehlt das Buchungsdatum.', rohzeile: [] })
|
||||
return
|
||||
}
|
||||
|
||||
const gegenstelle = gegenstelleLesen(eintrag, richtung)
|
||||
const zweckTeile = alleKnoten(eintrag, 'Ustrd')
|
||||
.map((k) => textVon(k))
|
||||
.filter((t): t is string => t !== null)
|
||||
const zusatz = textVon(knotenSuchen(eintrag, 'AddtlNtryInf'))
|
||||
const verwendungszweck =
|
||||
zweckTeile.length > 0 ? zweckTeile.join(' ') : zusatz
|
||||
|
||||
const grundschluessel = importReferenzBilden({
|
||||
datum,
|
||||
betragCent,
|
||||
richtung,
|
||||
gegenstelle,
|
||||
verwendungszweck,
|
||||
}).replace(/^csv:/, 'camt:')
|
||||
const bisher = schluesselZaehler.get(grundschluessel) ?? 0
|
||||
schluesselZaehler.set(grundschluessel, bisher + 1)
|
||||
|
||||
zeilen.push({
|
||||
zeilennummer: nummer,
|
||||
datum,
|
||||
wertstellung: valuta,
|
||||
betragCent,
|
||||
richtung,
|
||||
gegenstelle,
|
||||
verwendungszweck,
|
||||
importReferenz: bisher === 0 ? grundschluessel : `${grundschluessel}#${bisher + 1}`,
|
||||
dublette: false,
|
||||
})
|
||||
})
|
||||
|
||||
return { iban, waehrung, zeilen, fehler, fremdwaehrung }
|
||||
}
|
||||
|
||||
function datumAusKnoten(eintrag: XmlKnoten, name: string): Date | null {
|
||||
const knoten = knotenSuchen(eintrag, name)
|
||||
if (!knoten) return null
|
||||
const text = textVon(knotenSuchen(knoten, 'Dt')) ?? textVon(knotenSuchen(knoten, 'DtTm'))
|
||||
if (!text) return null
|
||||
const treffer = /^(\d{4})-(\d{2})-(\d{2})/.exec(text)
|
||||
if (!treffer) return null
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
Number.parseInt(treffer[1]!, 10),
|
||||
Number.parseInt(treffer[2]!, 10) - 1,
|
||||
Number.parseInt(treffer[3]!, 10),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bei einer Gutschrift ist die Gegenstelle der Auftraggeber (Dbtr),
|
||||
* bei einer Belastung der Empfaenger (Cdtr). Wer das vertauscht, hat im
|
||||
* Kassenbuch ueberall den eigenen Namen stehen.
|
||||
*/
|
||||
function gegenstelleLesen(eintrag: XmlKnoten, richtung: BuchungRichtung): string | null {
|
||||
const parteien = knotenSuchen(eintrag, 'RltdPties')
|
||||
if (!parteien) return null
|
||||
const gesucht = richtung === 'EINNAHME' ? 'Dbtr' : 'Cdtr'
|
||||
const partei = knotenSuchen(parteien, gesucht)
|
||||
return partei ? textVon(knotenSuchen(partei, 'Nm')) : null
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { addDays, addMonths, addYears, differenceInCalendarDays } from 'date-fns'
|
||||
|
||||
/**
|
||||
* Tagesdatum-Rechnung fuer die @db.Date-Felder des Finanzmoduls.
|
||||
*
|
||||
* Stichtage, Buchungsdaten und Bestellungsdatum sind reine Kalendertage
|
||||
* ohne Uhrzeit. Prisma liefert sie als Date auf UTC-Mitternacht. Rechnet
|
||||
* man darauf mit den lokalen date-fns-Funktionen, verschiebt sich in
|
||||
* jeder Zeitzone westlich von Greenwich der Tag - und eine Frist, ein
|
||||
* Betreuungsjahr oder ein Stichtag waere um einen Tag falsch.
|
||||
*
|
||||
* Deshalb laeuft jede Rechnung ueber einen "Spiegel": das Datum wird auf
|
||||
* die lokale Mittagszeit desselben Kalendertages gelegt, dort mit
|
||||
* date-fns gerechnet (so bleibt die Konvention "Datumsrechnung immer ueber
|
||||
* date-fns" gewahrt) und danach wieder auf UTC-Mitternacht zurueckgelegt.
|
||||
* Mittag statt Mitternacht, weil es Zeitzonen gibt, in denen die
|
||||
* Sommerzeit um 00:00 umgestellt wird und die lokale Mitternacht dann
|
||||
* gar nicht existiert.
|
||||
*/
|
||||
|
||||
function inSpiegel(datum: Date): Date {
|
||||
return new Date(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate(), 12, 0, 0, 0)
|
||||
}
|
||||
|
||||
function ausSpiegel(datum: Date): Date {
|
||||
return new Date(Date.UTC(datum.getFullYear(), datum.getMonth(), datum.getDate()))
|
||||
}
|
||||
|
||||
/** Schneidet eine eventuelle Uhrzeit ab: UTC-Mitternacht desselben Tages. */
|
||||
export function alsTag(datum: Date): Date {
|
||||
return new Date(Date.UTC(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate()))
|
||||
}
|
||||
|
||||
/** Der heutige Kalendertag - gelesen aus der lokalen Uhr des Servers. */
|
||||
export function heute(): Date {
|
||||
const jetzt = new Date()
|
||||
return new Date(Date.UTC(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate()))
|
||||
}
|
||||
|
||||
/** "2026-03-15" aus einem Datumsfeld des Formulars. Wirft bei Unsinn nicht. */
|
||||
export function tagAusText(text: string | null | undefined): Date | null {
|
||||
if (!text) return null
|
||||
const treffer = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text.trim())
|
||||
if (!treffer) return null
|
||||
const jahr = Number.parseInt(treffer[1]!, 10)
|
||||
const monat = Number.parseInt(treffer[2]!, 10)
|
||||
const tag = Number.parseInt(treffer[3]!, 10)
|
||||
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null
|
||||
const datum = new Date(Date.UTC(jahr, monat - 1, tag))
|
||||
// 31.02. faengt die Rueckrechnung ab.
|
||||
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tag) return null
|
||||
return datum
|
||||
}
|
||||
|
||||
/** Gegenstueck fuer den value eines <input type="date">. */
|
||||
export function tagAlsText(datum: Date | null | undefined): string {
|
||||
if (!datum) return ''
|
||||
const jahr = String(datum.getUTCFullYear()).padStart(4, '0')
|
||||
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
|
||||
const tag = String(datum.getUTCDate()).padStart(2, '0')
|
||||
return `${jahr}-${monat}-${tag}`
|
||||
}
|
||||
|
||||
export function tagePlus(datum: Date, tage: number): Date {
|
||||
return ausSpiegel(addDays(inSpiegel(datum), tage))
|
||||
}
|
||||
|
||||
export function monatePlus(datum: Date, monate: number): Date {
|
||||
return ausSpiegel(addMonths(inSpiegel(datum), monate))
|
||||
}
|
||||
|
||||
export function jahrePlus(datum: Date, jahre: number): Date {
|
||||
return ausSpiegel(addYears(inSpiegel(datum), jahre))
|
||||
}
|
||||
|
||||
/** Ganze Kalendertage zwischen zwei Tagesdaten. Negativ, wenn b vor a liegt. */
|
||||
export function tageZwischen(a: Date, b: Date): number {
|
||||
return differenceInCalendarDays(inSpiegel(b), inSpiegel(a))
|
||||
}
|
||||
|
||||
export function vorOderGleich(a: Date, b: Date): boolean {
|
||||
return alsTag(a).getTime() <= alsTag(b).getTime()
|
||||
}
|
||||
|
||||
export function nachOderGleich(a: Date, b: Date): boolean {
|
||||
return alsTag(a).getTime() >= alsTag(b).getTime()
|
||||
}
|
||||
|
||||
/** Beide Grenzen einschliesslich. */
|
||||
export function imZeitraum(datum: Date, von: Date, bis: Date): boolean {
|
||||
const t = alsTag(datum).getTime()
|
||||
return t >= alsTag(von).getTime() && t <= alsTag(bis).getTime()
|
||||
}
|
||||
|
||||
/** Aeltestes zuerst. */
|
||||
export function nachTagSortieren<T>(werte: T[], schluessel: (wert: T) => Date): T[] {
|
||||
return [...werte].sort(
|
||||
(a, b) => alsTag(schluessel(a)).getTime() - alsTag(schluessel(b)).getTime(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* IBAN-Pruefung nach ISO 13616 - selbst gerechnet, ohne Paket.
|
||||
*
|
||||
* Warum ueberhaupt pruefen? Weil eine falsch abgetippte IBAN im
|
||||
* Vermoegensverzeichnis beim Gericht landet und weil der Blindindex
|
||||
* (siehe blindindex() in src/lib/crypto/hash.ts) nur dann als "kenne ich
|
||||
* dieses Konto schon?" taugt, wenn die IBAN vorher normalisiert wurde.
|
||||
* Ein Leerzeichen zu viel wuerde sonst ein zweites, scheinbar neues
|
||||
* Konto erzeugen.
|
||||
*
|
||||
* Das Verfahren: die ersten vier Zeichen (Laendercode + Pruefziffer) ans
|
||||
* Ende stellen, jeden Buchstaben durch seine Position im Alphabet + 9
|
||||
* ersetzen (A=10 ... Z=35) und den entstandenen Riesen-Zahlstring modulo
|
||||
* 97 rechnen. Ergebnis muss 1 sein.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Laenge der IBAN je Land. Ohne diese Tabelle wuerde eine zu kurze oder
|
||||
* zu lange IBAN durchrutschen, wenn die Pruefziffer zufaellig passt.
|
||||
* Quelle: IBAN-Registry des SWIFT (Stand 2024).
|
||||
*/
|
||||
export const IBAN_LAENGEN: Record<string, number> = {
|
||||
AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22, BI: 27,
|
||||
BR: 29, BY: 28, CH: 21, CR: 22, CY: 28, CZ: 24, DE: 22, DJ: 27, DK: 18, DO: 28,
|
||||
EE: 20, EG: 29, ES: 24, FI: 18, FO: 18, FR: 27, GB: 22, GE: 22, GI: 23, GL: 18,
|
||||
GR: 27, GT: 28, HR: 21, HU: 28, IE: 22, IL: 23, IQ: 23, IS: 26, IT: 27, JO: 30,
|
||||
KW: 30, KZ: 20, LB: 28, LC: 32, LI: 21, LT: 20, LU: 20, LV: 21, LY: 25, MC: 27,
|
||||
MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30, NL: 18, NO: 15, PK: 24, PL: 28,
|
||||
PS: 29, PT: 25, QA: 29, RO: 24, RS: 22, RU: 33, SA: 24, SC: 31, SD: 18, SE: 24,
|
||||
SI: 19, SK: 24, SM: 27, SO: 23, ST: 25, SV: 28, TL: 23, TN: 24, TR: 26, UA: 29,
|
||||
VA: 22, VG: 24, XK: 20,
|
||||
}
|
||||
|
||||
/** Grossbuchstaben, keine Leerzeichen - so und nicht anders wird gespeichert. */
|
||||
export function ibanNormalisieren(iban: string | null | undefined): string {
|
||||
if (!iban) return ''
|
||||
return iban.replace(/[\s -]/g, '').toUpperCase()
|
||||
}
|
||||
|
||||
/** Vierergruppen, wie sie auf jedem Kontoauszug stehen. */
|
||||
export function ibanFormatieren(iban: string | null | undefined): string {
|
||||
const roh = ibanNormalisieren(iban)
|
||||
if (roh === '') return ''
|
||||
return roh.replace(/(.{4})/g, '$1 ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuer Listen und Berichte: DE12 **** **** 3456. Die vollstaendige IBAN
|
||||
* steht nur dort, wo sie gebraucht wird - im Vermoegensverzeichnis und
|
||||
* im Kontodetail.
|
||||
*/
|
||||
export function ibanMaskieren(iban: string | null | undefined): string {
|
||||
const roh = ibanNormalisieren(iban)
|
||||
if (roh.length < 8) return roh
|
||||
const anfang = roh.slice(0, 4)
|
||||
const ende = roh.slice(-4)
|
||||
const versteckt = '*'.repeat(Math.max(0, roh.length - 8))
|
||||
return ibanFormatieren(`${anfang}${versteckt}${ende}`)
|
||||
}
|
||||
|
||||
/** Modulo 97 ueber den Zahlstring, ziffernweise - kein BigInt noetig. */
|
||||
function modulo97(zahlstring: string): number {
|
||||
let rest = 0
|
||||
for (const zeichen of zahlstring) {
|
||||
rest = (rest * 10 + (zeichen.charCodeAt(0) - 48)) % 97
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
export type IbanPruefung =
|
||||
| { gueltig: true; iban: string; land: string; formatiert: string }
|
||||
| { gueltig: false; meldung: string }
|
||||
|
||||
/**
|
||||
* Prueft eine IBAN vollstaendig: Zeichenvorrat, Laenderlaenge und
|
||||
* Pruefziffer. Die Meldungen sagen, was zu tun ist, nicht was falsch war.
|
||||
*/
|
||||
export function ibanPruefen(eingabe: string | null | undefined): IbanPruefung {
|
||||
const iban = ibanNormalisieren(eingabe)
|
||||
|
||||
if (iban === '') {
|
||||
return { gueltig: false, meldung: 'Bitte eine IBAN angeben.' }
|
||||
}
|
||||
if (!/^[A-Z0-9]+$/.test(iban)) {
|
||||
return {
|
||||
gueltig: false,
|
||||
meldung: 'Die IBAN darf nur Buchstaben und Ziffern enthalten.',
|
||||
}
|
||||
}
|
||||
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(iban)) {
|
||||
return {
|
||||
gueltig: false,
|
||||
meldung: 'Eine IBAN beginnt mit zwei Buchstaben für das Land und zwei Prüfziffern, z.B. DE89.',
|
||||
}
|
||||
}
|
||||
|
||||
const land = iban.slice(0, 2)
|
||||
const erwartet = IBAN_LAENGEN[land]
|
||||
if (erwartet === undefined) {
|
||||
return {
|
||||
gueltig: false,
|
||||
meldung: `Das Länderkürzel "${land}" kenne ich nicht. Bitte die IBAN prüfen.`,
|
||||
}
|
||||
}
|
||||
if (iban.length !== erwartet) {
|
||||
return {
|
||||
gueltig: false,
|
||||
meldung: `Eine ${land}-IBAN hat ${erwartet} Zeichen, diese hat ${iban.length}.`,
|
||||
}
|
||||
}
|
||||
|
||||
const umgestellt = iban.slice(4) + iban.slice(0, 4)
|
||||
let zahlstring = ''
|
||||
for (const zeichen of umgestellt) {
|
||||
const code = zeichen.charCodeAt(0)
|
||||
zahlstring += code >= 65 ? String(code - 55) : zeichen
|
||||
}
|
||||
|
||||
if (modulo97(zahlstring) !== 1) {
|
||||
return {
|
||||
gueltig: false,
|
||||
meldung: 'Die Prüfziffer stimmt nicht. Vermutlich ein Zahlendreher – bitte noch einmal vergleichen.',
|
||||
}
|
||||
}
|
||||
|
||||
return { gueltig: true, iban, land, formatiert: ibanFormatieren(iban) }
|
||||
}
|
||||
|
||||
/** Kurzform fuer Bedingungen. */
|
||||
export function ibanGueltig(eingabe: string | null | undefined): boolean {
|
||||
return ibanPruefen(eingabe).gueltig
|
||||
}
|
||||
|
||||
/**
|
||||
* BIC nach ISO 9362: 8 oder 11 Stellen. Wird nur geprueft, nicht
|
||||
* erzwungen - fuer eine Barkasse oder ein Verwahrgeldkonto im Heim gibt
|
||||
* es keinen BIC.
|
||||
*/
|
||||
export function bicGueltig(eingabe: string | null | undefined): boolean {
|
||||
if (!eingabe || eingabe.trim() === '') return true
|
||||
const bic = eingabe.replace(/\s+/g, '').toUpperCase()
|
||||
return /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(bic)
|
||||
}
|
||||
|
||||
export function bicNormalisieren(eingabe: string | null | undefined): string | null {
|
||||
if (!eingabe) return null
|
||||
const bic = eingabe.replace(/\s+/g, '').toUpperCase()
|
||||
return bic === '' ? null : bic
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { BuchungRichtung } from '@prisma/client'
|
||||
import { betragParsen, BetragFehler } from '@/lib/finanzen/betrag'
|
||||
import { tagAlsText } from '@/lib/finanzen/datum'
|
||||
|
||||
/**
|
||||
* Import von Kontoumsaetzen aus CSV.
|
||||
*
|
||||
* Kontoauszuege abzutippen ist die Stelle, an der in der Rechnungslegung
|
||||
* die Fehler entstehen - ein Zahlendreher pro hundert Zeilen reicht, und
|
||||
* die Rechenprobe geht nicht mehr auf. Deshalb dieser Import.
|
||||
*
|
||||
* Er macht bewusst keine Annahmen ueber "das Format der Sparkasse" oder
|
||||
* "das Format der Volksbank". Stattdessen wird erkannt:
|
||||
*
|
||||
* - das Trennzeichen (Semikolon, Komma, Tabulator, senkrechter Strich),
|
||||
* - die Kodierung (UTF-8 oder Windows-1252/ISO-8859-1),
|
||||
* - die Spalten anhand der Ueberschriften,
|
||||
* - deutsche Zahlen ("1.234,56", auch mit nachgestelltem Minus),
|
||||
* - die ueblichen Datumsformate.
|
||||
*
|
||||
* Nichts wird stillschweigend geraten: was nicht erkannt wird, kommt als
|
||||
* Fehlerzeile zurueck und wird dem Nutzer gezeigt.
|
||||
*
|
||||
* Dublettenschutz laeuft ueber importReferenz. Der Schluessel ist ein
|
||||
* Hash ueber Datum, Betrag, Gegenstelle und Verwendungszweck; kommt
|
||||
* dieselbe Kombination in einer Datei mehrfach vor - zwei gleiche
|
||||
* Barabhebungen am selben Tag sind voellig normal -, bekommt jede
|
||||
* Wiederholung eine laufende Nummer angehaengt. So werden echte
|
||||
* Doppelbuchungen erkannt, ohne echte Wiederholungen zu verschlucken.
|
||||
*/
|
||||
|
||||
export type Trennzeichen = ';' | ',' | '\t' | '|'
|
||||
|
||||
const TRENNZEICHEN_KANDIDATEN: Trennzeichen[] = [';', ',', '\t', '|']
|
||||
|
||||
export type Spaltenzuordnung = {
|
||||
datum: number
|
||||
wertstellung: number | null
|
||||
betrag: number | null
|
||||
/** Getrennte Soll-/Haben-Spalten, wie sie manche Ausleitungen haben. */
|
||||
soll: number | null
|
||||
haben: number | null
|
||||
/** Spalte mit "S"/"H" bzw. "Soll"/"Haben". */
|
||||
sollHaben: number | null
|
||||
gegenstelle: number | null
|
||||
verwendungszweck: number | null
|
||||
buchungstext: number | null
|
||||
waehrung: number | null
|
||||
}
|
||||
|
||||
export type CsvErkennung = {
|
||||
trennzeichen: Trennzeichen
|
||||
kopfzeile: string[]
|
||||
zuordnung: Spaltenzuordnung
|
||||
datenzeilen: string[][]
|
||||
/** Zeilennummer der Kopfzeile in der Datei, ab 1 gezaehlt. */
|
||||
kopfzeileNummer: number
|
||||
}
|
||||
|
||||
export class ImportFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht)
|
||||
this.name = 'ImportFehler'
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Kodierung und Zerlegung
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Liest den Dateiinhalt als Text. Viele Banken liefern bis heute
|
||||
* Windows-1252; kommt beim UTF-8-Versuch ein Ersatzzeichen heraus, wird
|
||||
* auf latin1 zurueckgeschaltet - dann stimmen die Umlaute wieder.
|
||||
*/
|
||||
export function csvTextLesen(inhalt: Buffer | Uint8Array | string): string {
|
||||
if (typeof inhalt === 'string') return bomEntfernen(inhalt)
|
||||
const puffer = Buffer.isBuffer(inhalt) ? inhalt : Buffer.from(inhalt)
|
||||
const alsUtf8 = puffer.toString('utf8')
|
||||
if (alsUtf8.includes('�')) return bomEntfernen(puffer.toString('latin1'))
|
||||
return bomEntfernen(alsUtf8)
|
||||
}
|
||||
|
||||
function bomEntfernen(text: string): string {
|
||||
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt CSV nach RFC 4180: Anfuehrungszeichen schuetzen Trennzeichen
|
||||
* und Zeilenumbrueche, ein doppeltes Anfuehrungszeichen im Feld steht
|
||||
* fuer ein einzelnes.
|
||||
*/
|
||||
export function csvZerlegen(text: string, trennzeichen: Trennzeichen): string[][] {
|
||||
const zeilen: string[][] = []
|
||||
let zeile: string[] = []
|
||||
let feld = ''
|
||||
let inAnfuehrung = false
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const zeichen = text[i]!
|
||||
|
||||
if (inAnfuehrung) {
|
||||
if (zeichen === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
feld += '"'
|
||||
i++
|
||||
} else {
|
||||
inAnfuehrung = false
|
||||
}
|
||||
} else {
|
||||
feld += zeichen
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (zeichen === '"' && feld === '') {
|
||||
inAnfuehrung = true
|
||||
} else if (zeichen === trennzeichen) {
|
||||
zeile.push(feld)
|
||||
feld = ''
|
||||
} else if (zeichen === '\n' || zeichen === '\r') {
|
||||
if (zeichen === '\r' && text[i + 1] === '\n') i++
|
||||
zeile.push(feld)
|
||||
feld = ''
|
||||
zeilen.push(zeile)
|
||||
zeile = []
|
||||
} else {
|
||||
feld += zeichen
|
||||
}
|
||||
}
|
||||
|
||||
if (feld !== '' || zeile.length > 0) {
|
||||
zeile.push(feld)
|
||||
zeilen.push(zeile)
|
||||
}
|
||||
|
||||
return zeilen.filter((z) => z.some((f) => f.trim() !== ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Welches Trennzeichen? Gewaehlt wird das, bei dem die meisten Zeilen
|
||||
* dieselbe Spaltenzahl haben - das ist verlaesslicher als blosses
|
||||
* Zaehlen, weil in Verwendungszwecken oft Kommas stehen.
|
||||
*/
|
||||
export function trennzeichenErkennen(text: string): Trennzeichen {
|
||||
let bestes: Trennzeichen = ';'
|
||||
let bewertung = -1
|
||||
|
||||
for (const kandidat of TRENNZEICHEN_KANDIDATEN) {
|
||||
const zeilen = csvZerlegen(text, kandidat).slice(0, 20)
|
||||
if (zeilen.length === 0) continue
|
||||
const spalten = zeilen.map((z) => z.length)
|
||||
const haeufigste = spalten
|
||||
.sort((a, b) => a - b)
|
||||
[Math.floor(spalten.length / 2)]!
|
||||
if (haeufigste < 2) continue
|
||||
const passend = zeilen.filter((z) => z.length === haeufigste).length
|
||||
const punkte = haeufigste * 100 + passend
|
||||
if (punkte > bewertung) {
|
||||
bewertung = punkte
|
||||
bestes = kandidat
|
||||
}
|
||||
}
|
||||
|
||||
return bestes
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Spaltenerkennung
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function normalisieren(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
}
|
||||
|
||||
/** Reihenfolge zaehlt: der erste Treffer gewinnt. */
|
||||
const SPALTEN_MUSTER: { feld: keyof Spaltenzuordnung; begriffe: string[] }[] = [
|
||||
{ feld: 'wertstellung', begriffe: ['wertstellung', 'valutadatum', 'valuta', 'wertstellungsdatum'] },
|
||||
{ feld: 'datum', begriffe: ['buchungstag', 'buchungsdatum', 'belegdatum', 'datum', 'bookingdate'] },
|
||||
{ feld: 'soll', begriffe: ['soll', 'debit', 'ausgang', 'ausgabe', 'lastschrift'] },
|
||||
{ feld: 'haben', begriffe: ['haben', 'credit', 'eingang', 'einnahme', 'gutschrift'] },
|
||||
{ feld: 'sollHaben', begriffe: ['sollhaben', 'sh', 'sollhabenkennzeichen', 'debitcredit'] },
|
||||
{
|
||||
feld: 'betrag',
|
||||
begriffe: ['betrag', 'umsatz', 'amount', 'betrageur', 'betragineur', 'umsatzineur'],
|
||||
},
|
||||
{
|
||||
feld: 'gegenstelle',
|
||||
begriffe: [
|
||||
'beguenstigterzahlungspflichtiger',
|
||||
'beguenstigter',
|
||||
'zahlungspflichtiger',
|
||||
'auftraggeber',
|
||||
'empfaenger',
|
||||
'zahlungsempfaenger',
|
||||
'name',
|
||||
'kontoinhaber',
|
||||
'partnername',
|
||||
],
|
||||
},
|
||||
{
|
||||
feld: 'verwendungszweck',
|
||||
begriffe: ['verwendungszweck', 'vorgangverwendungszweck', 'zweck', 'referenz', 'remittanceinformation'],
|
||||
},
|
||||
{ feld: 'buchungstext', begriffe: ['buchungstext', 'vorgang', 'umsatzart', 'transaktionsart'] },
|
||||
{ feld: 'waehrung', begriffe: ['waehrung', 'currency', 'waehrungskennzeichen'] },
|
||||
]
|
||||
|
||||
function spaltenZuordnen(kopf: string[]): Spaltenzuordnung {
|
||||
const normalisiert = kopf.map((k) => normalisieren(k))
|
||||
const belegt = new Set<number>()
|
||||
const treffer: Partial<Record<keyof Spaltenzuordnung, number>> = {}
|
||||
|
||||
/**
|
||||
* Zwei Durchlaeufe: erst exakte Ueberschriften, dann Teiltreffer. Ohne
|
||||
* diese Reihenfolge wuerde "Soll/Haben" als Betragsspalte "Soll"
|
||||
* gelesen - und jede Gutschrift waere eine Ausgabe.
|
||||
*/
|
||||
const suchen = (passt: (ueberschrift: string, begriff: string) => boolean) => {
|
||||
for (const muster of SPALTEN_MUSTER) {
|
||||
if (treffer[muster.feld] !== undefined) continue
|
||||
for (const begriff of muster.begriffe) {
|
||||
const index = normalisiert.findIndex(
|
||||
(k, i) => !belegt.has(i) && k !== '' && passt(k, begriff),
|
||||
)
|
||||
if (index >= 0) {
|
||||
treffer[muster.feld] = index
|
||||
belegt.add(index)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suchen((k, begriff) => k === begriff)
|
||||
// Kurze Begriffe wie "sh" oder "name" nur exakt - sonst treffen sie
|
||||
// in halben Ueberschriften mit.
|
||||
suchen((k, begriff) => begriff.length >= 5 && k.includes(begriff))
|
||||
|
||||
// Gibt es nur eine Valuta-Spalte und kein Buchungsdatum, ist die
|
||||
// Valuta das Datum. Besser eine belegte Spalte als ein Abbruch.
|
||||
if (treffer.datum === undefined && treffer.wertstellung !== undefined) {
|
||||
treffer.datum = treffer.wertstellung
|
||||
treffer.wertstellung = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
datum: treffer.datum ?? -1,
|
||||
wertstellung: treffer.wertstellung ?? null,
|
||||
betrag: treffer.betrag ?? null,
|
||||
soll: treffer.soll ?? null,
|
||||
haben: treffer.haben ?? null,
|
||||
sollHaben: treffer.sollHaben ?? null,
|
||||
gegenstelle: treffer.gegenstelle ?? null,
|
||||
verwendungszweck: treffer.verwendungszweck ?? null,
|
||||
buchungstext: treffer.buchungstext ?? null,
|
||||
waehrung: treffer.waehrung ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet die Kopfzeile. Manche Ausleitungen stellen der Tabelle noch
|
||||
* zwei, drei Zeilen mit Kontonummer und Zeitraum voran - deshalb wird
|
||||
* nicht blind die erste Zeile genommen, sondern die erste, in der sich
|
||||
* eine Datums- und eine Betragsspalte erkennen laesst.
|
||||
*/
|
||||
export function csvErkennen(inhalt: Buffer | Uint8Array | string): CsvErkennung {
|
||||
const text = csvTextLesen(inhalt)
|
||||
if (text.trim() === '') throw new ImportFehler('Die Datei ist leer.')
|
||||
|
||||
const trennzeichen = trennzeichenErkennen(text)
|
||||
const zeilen = csvZerlegen(text, trennzeichen)
|
||||
if (zeilen.length === 0) throw new ImportFehler('Die Datei enthält keine lesbaren Zeilen.')
|
||||
|
||||
for (let i = 0; i < Math.min(zeilen.length, 10); i++) {
|
||||
const kopf = zeilen[i]!
|
||||
const zuordnung = spaltenZuordnen(kopf)
|
||||
const hatBetrag =
|
||||
zuordnung.betrag !== null || zuordnung.soll !== null || zuordnung.haben !== null
|
||||
if (zuordnung.datum >= 0 && hatBetrag) {
|
||||
return {
|
||||
trennzeichen,
|
||||
kopfzeile: kopf,
|
||||
zuordnung,
|
||||
datenzeilen: zeilen.slice(i + 1),
|
||||
kopfzeileNummer: i + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImportFehler(
|
||||
'In der Datei ist keine Kopfzeile mit einer Datums- und einer Betragsspalte zu finden. ' +
|
||||
'Bitte die Datei aus dem Online-Banking noch einmal als CSV mit Spaltenüberschriften ausgeben.',
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Datum
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Liest die Datumsformate, die in Bankausleitungen vorkommen.
|
||||
* Zweistellige Jahre: 00-79 heisst 20xx, 80-99 heisst 19xx - vor 1980
|
||||
* gab es keine CSV-Ausleitung, und ein Kontoauszug von 2079 kommt fuer
|
||||
* diese Anwendung nicht mehr in Betracht.
|
||||
*/
|
||||
export function datumParsen(eingabe: string | null | undefined): Date | null {
|
||||
if (!eingabe) return null
|
||||
const text = eingabe.trim()
|
||||
if (text === '') return null
|
||||
|
||||
let jahr: number
|
||||
let monat: number
|
||||
let tag: number
|
||||
|
||||
const deutsch = /^(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2}|\d{4})$/.exec(text)
|
||||
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ].*)?$/.exec(text)
|
||||
|
||||
if (deutsch) {
|
||||
tag = Number.parseInt(deutsch[1]!, 10)
|
||||
monat = Number.parseInt(deutsch[2]!, 10)
|
||||
const jahrText = deutsch[3]!
|
||||
jahr =
|
||||
jahrText.length === 4
|
||||
? Number.parseInt(jahrText, 10)
|
||||
: Number.parseInt(jahrText, 10) < 80
|
||||
? 2000 + Number.parseInt(jahrText, 10)
|
||||
: 1900 + Number.parseInt(jahrText, 10)
|
||||
} else if (iso) {
|
||||
jahr = Number.parseInt(iso[1]!, 10)
|
||||
monat = Number.parseInt(iso[2]!, 10)
|
||||
tag = Number.parseInt(iso[3]!, 10)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null
|
||||
const datum = new Date(Date.UTC(jahr, monat - 1, tag))
|
||||
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tag) return null
|
||||
return datum
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Umwandlung in Buchungen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type ImportZeile = {
|
||||
/** Zeilennummer in der Datei, ab 1 - damit der Nutzer sie wiederfindet. */
|
||||
zeilennummer: number
|
||||
datum: Date
|
||||
wertstellung: Date | null
|
||||
/** Immer positiv; die Richtung steht daneben. */
|
||||
betragCent: bigint
|
||||
richtung: BuchungRichtung
|
||||
gegenstelle: string | null
|
||||
verwendungszweck: string | null
|
||||
importReferenz: string
|
||||
/** Wird von dublettenMarkieren gesetzt. */
|
||||
dublette: boolean
|
||||
}
|
||||
|
||||
export type ImportZeilenfehler = {
|
||||
zeilennummer: number
|
||||
meldung: string
|
||||
rohzeile: string[]
|
||||
}
|
||||
|
||||
export type ImportErgebnis = {
|
||||
erkennung: CsvErkennung
|
||||
zeilen: ImportZeile[]
|
||||
fehler: ImportZeilenfehler[]
|
||||
/** Zeilen, deren Waehrung nicht EUR ist - werden nicht uebernommen. */
|
||||
fremdwaehrung: number
|
||||
}
|
||||
|
||||
function textOderNull(werte: string[], index: number | null): string | null {
|
||||
if (index === null || index < 0) return null
|
||||
const wert = (werte[index] ?? '').replace(/\s+/g, ' ').trim()
|
||||
return wert === '' ? null : wert
|
||||
}
|
||||
|
||||
/**
|
||||
* Stabiler Schluessel je Umsatz. Absichtlich NICHT die Referenznummer der
|
||||
* Bank: die ist nicht in jedem Format enthalten und aendert sich beim
|
||||
* erneuten Ausleiten. Datum, Betrag, Gegenstelle und Zweck dagegen
|
||||
* bleiben gleich.
|
||||
*/
|
||||
export function importReferenzBilden(teile: {
|
||||
datum: Date
|
||||
betragCent: bigint
|
||||
richtung: BuchungRichtung
|
||||
gegenstelle: string | null
|
||||
verwendungszweck: string | null
|
||||
}): string {
|
||||
const roh = [
|
||||
tagAlsText(teile.datum),
|
||||
teile.richtung,
|
||||
teile.betragCent.toString(),
|
||||
(teile.gegenstelle ?? '').toLowerCase(),
|
||||
(teile.verwendungszweck ?? '').toLowerCase(),
|
||||
].join('|')
|
||||
return `csv:${createHash('sha256').update(roh, 'utf8').digest('hex').slice(0, 32)}`
|
||||
}
|
||||
|
||||
export function csvZuBuchungen(inhalt: Buffer | Uint8Array | string): ImportErgebnis {
|
||||
const erkennung = csvErkennen(inhalt)
|
||||
const { zuordnung } = erkennung
|
||||
|
||||
const zeilen: ImportZeile[] = []
|
||||
const fehler: ImportZeilenfehler[] = []
|
||||
let fremdwaehrung = 0
|
||||
|
||||
// Zaehlt, wie oft ein Schluessel innerhalb dieser Datei vorkommt -
|
||||
// zwei gleiche Abhebungen am selben Tag sind kein Fehler.
|
||||
const schluesselZaehler = new Map<string, number>()
|
||||
|
||||
erkennung.datenzeilen.forEach((werte, i) => {
|
||||
const zeilennummer = erkennung.kopfzeileNummer + 1 + i
|
||||
|
||||
const waehrung = textOderNull(werte, zuordnung.waehrung)
|
||||
if (waehrung && !/^(eur|€)$/i.test(waehrung)) {
|
||||
fremdwaehrung++
|
||||
fehler.push({
|
||||
zeilennummer,
|
||||
meldung: `Währung "${waehrung}" wird nicht übernommen. Diese Anwendung rechnet in Euro.`,
|
||||
rohzeile: werte,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const datum = datumParsen(textOderNull(werte, zuordnung.datum))
|
||||
if (!datum) {
|
||||
fehler.push({
|
||||
zeilennummer,
|
||||
meldung: `Das Datum "${werte[zuordnung.datum] ?? ''}" ist nicht lesbar. Erwartet wird TT.MM.JJJJ.`,
|
||||
rohzeile: werte,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let betragCent: bigint
|
||||
let richtung: BuchungRichtung
|
||||
|
||||
try {
|
||||
const ergebnis = betragUndRichtung(werte, zuordnung)
|
||||
betragCent = ergebnis.betragCent
|
||||
richtung = ergebnis.richtung
|
||||
} catch (ausnahme) {
|
||||
fehler.push({
|
||||
zeilennummer,
|
||||
meldung:
|
||||
ausnahme instanceof BetragFehler || ausnahme instanceof ImportFehler
|
||||
? ausnahme.message
|
||||
: 'Der Betrag ist nicht lesbar.',
|
||||
rohzeile: werte,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (betragCent === 0n) {
|
||||
fehler.push({
|
||||
zeilennummer,
|
||||
meldung: 'Der Betrag ist 0,00 € – die Zeile wurde übersprungen.',
|
||||
rohzeile: werte,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const gegenstelle = textOderNull(werte, zuordnung.gegenstelle)
|
||||
const zweckTeile = [
|
||||
textOderNull(werte, zuordnung.buchungstext),
|
||||
textOderNull(werte, zuordnung.verwendungszweck),
|
||||
].filter((t): t is string => t !== null)
|
||||
const verwendungszweck = zweckTeile.length > 0 ? zweckTeile.join(' · ') : null
|
||||
|
||||
const grundschluessel = importReferenzBilden({
|
||||
datum,
|
||||
betragCent,
|
||||
richtung,
|
||||
gegenstelle,
|
||||
verwendungszweck,
|
||||
})
|
||||
const bisher = schluesselZaehler.get(grundschluessel) ?? 0
|
||||
schluesselZaehler.set(grundschluessel, bisher + 1)
|
||||
const importReferenz = bisher === 0 ? grundschluessel : `${grundschluessel}#${bisher + 1}`
|
||||
|
||||
zeilen.push({
|
||||
zeilennummer,
|
||||
datum,
|
||||
wertstellung: datumParsen(textOderNull(werte, zuordnung.wertstellung)),
|
||||
betragCent,
|
||||
richtung,
|
||||
gegenstelle,
|
||||
verwendungszweck,
|
||||
importReferenz,
|
||||
dublette: false,
|
||||
})
|
||||
})
|
||||
|
||||
return { erkennung, zeilen, fehler, fremdwaehrung }
|
||||
}
|
||||
|
||||
function betragUndRichtung(
|
||||
werte: string[],
|
||||
zuordnung: Spaltenzuordnung,
|
||||
): { betragCent: bigint; richtung: BuchungRichtung } {
|
||||
// Getrennte Soll-/Haben-Spalten
|
||||
if (zuordnung.soll !== null || zuordnung.haben !== null) {
|
||||
const sollText = textOderNull(werte, zuordnung.soll)
|
||||
const habenText = textOderNull(werte, zuordnung.haben)
|
||||
const soll = sollText ? betragParsen(sollText) : 0n
|
||||
const haben = habenText ? betragParsen(habenText) : 0n
|
||||
if (soll !== 0n && haben !== 0n) {
|
||||
throw new ImportFehler('In dieser Zeile stehen Soll und Haben gleichzeitig.')
|
||||
}
|
||||
if (haben !== 0n) {
|
||||
return { betragCent: haben < 0n ? -haben : haben, richtung: 'EINNAHME' }
|
||||
}
|
||||
return { betragCent: soll < 0n ? -soll : soll, richtung: 'AUSGABE' }
|
||||
}
|
||||
|
||||
if (zuordnung.betrag === null) {
|
||||
throw new ImportFehler('In dieser Zeile ist keine Betragsspalte zu finden.')
|
||||
}
|
||||
|
||||
const roh = textOderNull(werte, zuordnung.betrag)
|
||||
if (roh === null) throw new ImportFehler('Der Betrag fehlt.')
|
||||
let betrag = betragParsen(roh)
|
||||
|
||||
// Eigene Soll/Haben-Spalte hat Vorrang vor dem Vorzeichen.
|
||||
const kennzeichen = textOderNull(werte, zuordnung.sollHaben)
|
||||
if (kennzeichen) {
|
||||
const normal = normalisieren(kennzeichen)
|
||||
if (normal === 's' || normal === 'soll' || normal === 'd' || normal === 'debit') {
|
||||
betrag = betrag < 0n ? betrag : -betrag
|
||||
} else if (normal === 'h' || normal === 'haben' || normal === 'c' || normal === 'credit') {
|
||||
betrag = betrag < 0n ? -betrag : betrag
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
betragCent: betrag < 0n ? -betrag : betrag,
|
||||
richtung: betrag < 0n ? 'AUSGABE' : 'EINNAHME',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert die Zeilen, deren importReferenz schon in der Datenbank
|
||||
* steht. Sie werden angezeigt, aber nicht uebernommen - so sieht der
|
||||
* Nutzer, dass die Datei sich mit einer frueheren ueberschneidet, statt
|
||||
* sich zu wundern, warum weniger Zeilen ankommen.
|
||||
*/
|
||||
export function dublettenMarkieren(
|
||||
zeilen: ImportZeile[],
|
||||
vorhandeneReferenzen: Iterable<string>,
|
||||
): { zeilen: ImportZeile[]; dubletten: number } {
|
||||
const vorhanden = new Set(vorhandeneReferenzen)
|
||||
let dubletten = 0
|
||||
const markiert = zeilen.map((zeile) => {
|
||||
const dublette = vorhanden.has(zeile.importReferenz)
|
||||
if (dublette) dubletten++
|
||||
return { ...zeile, dublette }
|
||||
})
|
||||
return { zeilen: markiert, dubletten }
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { BuchungKategorie, BuchungRichtung } 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
|
||||
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
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return !buchung.belegId && !(buchung.belegFehltBegruendung ?? '').trim()
|
||||
}
|
||||
|
||||
export function belegBegruendet(buchung: BuchungEingabe): boolean {
|
||||
return !buchung.belegId && (buchung.belegFehltBegruendung ?? '').trim() !== ''
|
||||
}
|
||||
|
||||
export type Belegquote = {
|
||||
gesamt: number
|
||||
mitBeleg: number
|
||||
begruendet: number
|
||||
ohneNachweis: 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 begruendet = buchungen.filter((b) => belegBegruendet(b)).length
|
||||
const ohneNachweis = buchungen.filter((b) => belegFehlt(b)).length
|
||||
const quoteProzent =
|
||||
gesamt === 0 ? 100 : Math.round(((mitBeleg + begruendet) / gesamt) * 100)
|
||||
return { gesamt, mitBeleg, begruendet, ohneNachweis, 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<BuchungKategorie, BuchungEingabe[]>()
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type {
|
||||
BuchungKategorie,
|
||||
BuchungRichtung,
|
||||
KontoArt,
|
||||
VermoegensArt,
|
||||
} from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Beschriftungen und Ordnung der Finanz-Enums.
|
||||
*
|
||||
* Die Reihenfolge in den Listen ist nicht alphabetisch, sondern die
|
||||
* Reihenfolge, in der die Posten im Vermoegensverzeichnis und in der
|
||||
* Rechnungslegung beim Gericht stehen sollen. Wer sie aendert, aendert
|
||||
* das Aussehen beider Berichte.
|
||||
*/
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Konten
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const KONTO_ARTEN: KontoArt[] = [
|
||||
'GIRO',
|
||||
'SPAR',
|
||||
'TAGESGELD',
|
||||
'FESTGELD',
|
||||
'DEPOT',
|
||||
'VERWAHRGELD_HEIM',
|
||||
'BARKASSE',
|
||||
'SONSTIGES',
|
||||
]
|
||||
|
||||
export const KONTO_ART_BESCHRIFTUNG: Record<KontoArt, string> = {
|
||||
GIRO: 'Girokonto',
|
||||
SPAR: 'Sparkonto',
|
||||
TAGESGELD: 'Tagesgeld',
|
||||
FESTGELD: 'Festgeld',
|
||||
DEPOT: 'Wertpapierdepot',
|
||||
VERWAHRGELD_HEIM: 'Verwahrgeld im Heim',
|
||||
BARKASSE: 'Barkasse',
|
||||
SONSTIGES: 'Sonstiges Konto',
|
||||
}
|
||||
|
||||
export const KONTO_ART_ERKLAERUNG: Record<KontoArt, string> = {
|
||||
GIRO: 'Laufendes Konto, über das Rente, Heimkosten und Alltagszahlungen laufen.',
|
||||
SPAR: 'Sparbuch oder Sparkonto. Zählt beim Sozialhilfeträger voll zum Vermögen.',
|
||||
TAGESGELD: 'Täglich verfügbares Guthaben, meist als Unterkonto zum Girokonto.',
|
||||
FESTGELD: 'Angelegtes Guthaben mit fester Laufzeit. Kündigungsfrist im Blick behalten.',
|
||||
DEPOT: 'Wertpapiere. Der Wert schwankt – Stichtagswerte deshalb belegen.',
|
||||
VERWAHRGELD_HEIM:
|
||||
'Taschengeldkonto, das die Einrichtung führt. Auch dieses Geld gehört zum Vermögen und muss abgerechnet werden.',
|
||||
BARKASSE: 'Bargeld, das du selbst verwahrst. Jede Entnahme braucht eine Quittung.',
|
||||
SONSTIGES: 'Alles, was in keine der anderen Arten passt.',
|
||||
}
|
||||
|
||||
/** Konten ohne IBAN - dort ist das Feld optional und wird nicht erzwungen. */
|
||||
export const KONTO_ARTEN_OHNE_IBAN: KontoArt[] = ['VERWAHRGELD_HEIM', 'BARKASSE', 'SONSTIGES']
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Buchungen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const BUCHUNG_RICHTUNGEN: BuchungRichtung[] = ['EINNAHME', 'AUSGABE', 'UMBUCHUNG']
|
||||
|
||||
export const RICHTUNG_BESCHRIFTUNG: Record<BuchungRichtung, string> = {
|
||||
EINNAHME: 'Einnahme',
|
||||
AUSGABE: 'Ausgabe',
|
||||
UMBUCHUNG: 'Umbuchung',
|
||||
}
|
||||
|
||||
/** Einnahmen in der Reihenfolge, in der sie im Bericht stehen. */
|
||||
export const EINNAHME_KATEGORIEN: BuchungKategorie[] = [
|
||||
'RENTE',
|
||||
'PFLEGEKASSE',
|
||||
'SOZIALHILFE',
|
||||
'LANDESPFLEGEGELD',
|
||||
'ZINSEN',
|
||||
'MIETEINNAHME',
|
||||
'SONSTIGE_EINNAHME',
|
||||
]
|
||||
|
||||
export const AUSGABE_KATEGORIEN: BuchungKategorie[] = [
|
||||
'HEIMKOSTEN',
|
||||
'BARBETRAG',
|
||||
'BEKLEIDUNG',
|
||||
'ARZT_UND_MEDIKAMENTE',
|
||||
'ZUZAHLUNG',
|
||||
'VERSICHERUNG',
|
||||
'IMMOBILIE_BETRIEBSKOSTEN',
|
||||
'IMMOBILIE_INSTANDHALTUNG',
|
||||
'GRUNDSTEUER',
|
||||
'STEUERN',
|
||||
'GEBUEHREN_GERICHT',
|
||||
'PORTO_UND_KOPIEN',
|
||||
'FAHRTKOSTEN',
|
||||
'TELEFON_INTERNET',
|
||||
'RUNDFUNKBEITRAG',
|
||||
'BESTATTUNGSVORSORGE',
|
||||
'BANKGEBUEHREN',
|
||||
'SONSTIGE_AUSGABE',
|
||||
]
|
||||
|
||||
export const ALLE_KATEGORIEN: BuchungKategorie[] = [
|
||||
...EINNAHME_KATEGORIEN,
|
||||
...AUSGABE_KATEGORIEN,
|
||||
'UMBUCHUNG',
|
||||
]
|
||||
|
||||
export const KATEGORIE_BESCHRIFTUNG: Record<BuchungKategorie, string> = {
|
||||
RENTE: 'Rente',
|
||||
PFLEGEKASSE: 'Leistungen der Pflegekasse',
|
||||
SOZIALHILFE: 'Hilfe zur Pflege / Sozialhilfe',
|
||||
LANDESPFLEGEGELD: 'Landespflegegeld',
|
||||
ZINSEN: 'Zinsen und Kapitalerträge',
|
||||
MIETEINNAHME: 'Mieteinnahmen',
|
||||
SONSTIGE_EINNAHME: 'Sonstige Einnahmen',
|
||||
HEIMKOSTEN: 'Heimkosten',
|
||||
BARBETRAG: 'Barbetrag / Taschengeld',
|
||||
BEKLEIDUNG: 'Bekleidung',
|
||||
ARZT_UND_MEDIKAMENTE: 'Arzt und Medikamente',
|
||||
ZUZAHLUNG: 'Zuzahlungen',
|
||||
VERSICHERUNG: 'Versicherungsbeiträge',
|
||||
IMMOBILIE_BETRIEBSKOSTEN: 'Immobilie: Betriebskosten',
|
||||
IMMOBILIE_INSTANDHALTUNG: 'Immobilie: Instandhaltung',
|
||||
GRUNDSTEUER: 'Grundsteuer',
|
||||
STEUERN: 'Steuern',
|
||||
GEBUEHREN_GERICHT: 'Gerichtskosten und Gebühren',
|
||||
PORTO_UND_KOPIEN: 'Porto und Kopien',
|
||||
FAHRTKOSTEN: 'Fahrtkosten',
|
||||
TELEFON_INTERNET: 'Telefon und Internet',
|
||||
RUNDFUNKBEITRAG: 'Rundfunkbeitrag',
|
||||
BESTATTUNGSVORSORGE: 'Bestattungsvorsorge',
|
||||
BANKGEBUEHREN: 'Bankgebühren',
|
||||
SONSTIGE_AUSGABE: 'Sonstige Ausgaben',
|
||||
UMBUCHUNG: 'Umbuchung zwischen eigenen Konten',
|
||||
}
|
||||
|
||||
/**
|
||||
* Passt die Kategorie zur Richtung? Wird nicht erzwungen (eine
|
||||
* Rueckerstattung von Heimkosten ist eine Einnahme in der Kategorie
|
||||
* HEIMKOSTEN), aber die Oberflaeche sortiert danach vor.
|
||||
*/
|
||||
export function kategorienFuerRichtung(richtung: BuchungRichtung): BuchungKategorie[] {
|
||||
if (richtung === 'EINNAHME') return EINNAHME_KATEGORIEN
|
||||
if (richtung === 'AUSGABE') return AUSGABE_KATEGORIEN
|
||||
return ['UMBUCHUNG']
|
||||
}
|
||||
|
||||
export function standardKategorie(richtung: BuchungRichtung): BuchungKategorie {
|
||||
if (richtung === 'EINNAHME') return 'SONSTIGE_EINNAHME'
|
||||
if (richtung === 'AUSGABE') return 'SONSTIGE_AUSGABE'
|
||||
return 'UMBUCHUNG'
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorzeichen fuer die Anzeige und fuer den Kontostand: Einnahmen erhoehen,
|
||||
* Ausgaben mindern. Umbuchungen bekommen kein Vorzeichen, weil sie am
|
||||
* Gesamtvermoegen nichts aendern - sie schieben nur zwischen Konten.
|
||||
*/
|
||||
export function vorzeichen(richtung: BuchungRichtung): 1n | -1n | 0n {
|
||||
if (richtung === 'EINNAHME') return 1n
|
||||
if (richtung === 'AUSGABE') return -1n
|
||||
return 0n
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Vermoegensposten
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reihenfolge der Gruppen im Vermoegensverzeichnis nach § 1835 BGB:
|
||||
* erst das, was sofort verfuegbar ist, dann Sachwerte, zuletzt
|
||||
* Forderungen. VERBINDLICHKEIT steht nicht in dieser Liste - Passiva
|
||||
* bekommen einen eigenen Abschnitt.
|
||||
*/
|
||||
export const AKTIVA_ARTEN: VermoegensArt[] = [
|
||||
'KONTO',
|
||||
'IMMOBILIE',
|
||||
'VERSICHERUNG',
|
||||
'FAHRZEUG',
|
||||
'WERTGEGENSTAND',
|
||||
'FORDERUNG',
|
||||
'SONSTIGES',
|
||||
]
|
||||
|
||||
export const VERMOEGENS_ARTEN: VermoegensArt[] = [...AKTIVA_ARTEN, 'VERBINDLICHKEIT']
|
||||
|
||||
export const VERMOEGENS_ART_BESCHRIFTUNG: Record<VermoegensArt, string> = {
|
||||
KONTO: 'Konten und Guthaben',
|
||||
IMMOBILIE: 'Grundbesitz',
|
||||
VERSICHERUNG: 'Versicherungen',
|
||||
FAHRZEUG: 'Fahrzeuge',
|
||||
WERTGEGENSTAND: 'Wertgegenstände',
|
||||
FORDERUNG: 'Forderungen',
|
||||
SONSTIGES: 'Sonstiges Vermögen',
|
||||
VERBINDLICHKEIT: 'Verbindlichkeiten',
|
||||
}
|
||||
|
||||
/** Einzahl, fuer die Kopfzeile eines einzelnen Postens. */
|
||||
export const VERMOEGENS_ART_EINZAHL: Record<VermoegensArt, string> = {
|
||||
KONTO: 'Konto',
|
||||
IMMOBILIE: 'Grundbesitz',
|
||||
VERSICHERUNG: 'Versicherung',
|
||||
FAHRZEUG: 'Fahrzeug',
|
||||
WERTGEGENSTAND: 'Wertgegenstand',
|
||||
FORDERUNG: 'Forderung',
|
||||
SONSTIGES: 'Sonstiges',
|
||||
VERBINDLICHKEIT: 'Verbindlichkeit',
|
||||
}
|
||||
|
||||
export function istPassiv(art: VermoegensArt): boolean {
|
||||
return art === 'VERBINDLICHKEIT'
|
||||
}
|
||||
|
||||
export const SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG = {
|
||||
UNGEPRUEFT: 'noch nicht geprüft',
|
||||
VERWERTBAR: 'verwertbar',
|
||||
GESCHUETZT: 'geschützt (Schonvermögen)',
|
||||
STRITTIG: 'strittig',
|
||||
} as const
|
||||
@@ -0,0 +1,356 @@
|
||||
import { summe } from '@/lib/finanzen/betrag'
|
||||
import { alsTag, imZeitraum, nachTagSortieren, tagePlus, vorOderGleich } from '@/lib/finanzen/datum'
|
||||
import type { Betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
|
||||
import {
|
||||
ausgabenGruppiert,
|
||||
belegFehlt,
|
||||
einnahmenGruppiert,
|
||||
istVermoegensbuchung,
|
||||
belegquote,
|
||||
type Belegquote,
|
||||
type BuchungEingabe,
|
||||
type KategorieGruppe,
|
||||
} from '@/lib/finanzen/kassenbuch'
|
||||
import { KONTO_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
|
||||
import type { KontoArt } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Rechnungslegung nach §§ 1859, 1865 BGB.
|
||||
*
|
||||
* Aufbau, den das Gericht erwartet:
|
||||
*
|
||||
* Anfangsbestand
|
||||
* + Einnahmen (nach Kategorien geordnet)
|
||||
* - Ausgaben (nach Kategorien geordnet)
|
||||
* = Endbestand
|
||||
*
|
||||
* Die letzte Zeile ist eine Behauptung, und sie wird geprueft. Der
|
||||
* Endbestand aus den Saldostaenden muss dem herausgerechneten Wert
|
||||
* entsprechen. Tut er das nicht, fehlen Buchungen oder Saldostaende.
|
||||
*
|
||||
* Diese Abweichung wird ausgewiesen, nicht versteckt. Ein Bericht, der
|
||||
* eine Differenz stillschweigend glattzieht, ist schlimmer als einer,
|
||||
* der sie benennt: die Zahl waere dann falsch, ohne dass es jemand
|
||||
* merkt - und unterschrieben wird sie trotzdem.
|
||||
*
|
||||
* Zwei Varianten:
|
||||
*
|
||||
* - § 1859 BGB - befreiter Betreuer (Angehoerige). Es genuegt eine
|
||||
* Vermoegensuebersicht: Bestaende und Summen je Kategorie, keine
|
||||
* Einzelbelegliste.
|
||||
* - § 1865 BGB - vollstaendige Rechnungslegung. Jede Buchung einzeln,
|
||||
* mit laufender Belegnummer.
|
||||
*/
|
||||
|
||||
export type Variante = 'UEBERSICHT_1859' | 'VOLL_1865'
|
||||
|
||||
export const VARIANTE_BESCHRIFTUNG: Record<Variante, string> = {
|
||||
UEBERSICHT_1859: 'Vermögensübersicht (§ 1859 BGB, befreiter Betreuer)',
|
||||
VOLL_1865: 'Vollständige Rechnungslegung (§ 1865 BGB)',
|
||||
}
|
||||
|
||||
export const VARIANTE_RECHTSGRUNDLAGE: Record<Variante, string> = {
|
||||
UEBERSICHT_1859: '§ 1859 BGB',
|
||||
VOLL_1865: '§ 1865 BGB',
|
||||
}
|
||||
|
||||
export function standardVariante(befreiterBetreuer: boolean): Variante {
|
||||
return befreiterBetreuer ? 'UEBERSICHT_1859' : 'VOLL_1865'
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Bestaende
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type SaldoEingabe = { stichtag: Date; betragCent: bigint; quelle: string | null }
|
||||
|
||||
export type KontoEingabe = {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
bank: string | null
|
||||
art: KontoArt
|
||||
istBetreuerkonto: boolean
|
||||
ibanMaskiert?: string | null
|
||||
saldostaende: SaldoEingabe[]
|
||||
}
|
||||
|
||||
export type Bestandszeile = {
|
||||
kontoId: string
|
||||
bezeichnung: string
|
||||
bank: string | null
|
||||
art: KontoArt
|
||||
artBeschriftung: string
|
||||
ibanMaskiert: string | null
|
||||
anfangCent: bigint
|
||||
/** Stichtag des tatsaechlich benutzten Saldostands - null, wenn keiner da war. */
|
||||
anfangStichtag: Date | null
|
||||
endeCent: bigint
|
||||
endeStichtag: Date | null
|
||||
}
|
||||
|
||||
/** Der juengste Saldostand, der nicht nach dem Stichtag liegt. */
|
||||
export function saldoZumStichtag(
|
||||
saldostaende: SaldoEingabe[],
|
||||
stichtag: Date,
|
||||
): SaldoEingabe | null {
|
||||
let treffer: SaldoEingabe | null = null
|
||||
for (const saldo of saldostaende) {
|
||||
if (!vorOderGleich(saldo.stichtag, stichtag)) continue
|
||||
if (treffer === null || alsTag(saldo.stichtag).getTime() > alsTag(treffer.stichtag).getTime()) {
|
||||
treffer = saldo
|
||||
}
|
||||
}
|
||||
return treffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestaende zu Beginn und zum Ende des Zeitraums.
|
||||
*
|
||||
* Ein Saldostand gilt als Bestand am Ende seines Stichtages. Der
|
||||
* Anfangsbestand eines Zeitraums ist deshalb der Bestand am Tag VOR dem
|
||||
* ersten Tag - sonst waeren die Buchungen des ersten Tages doppelt
|
||||
* enthalten.
|
||||
*
|
||||
* Konten des Betreuers bleiben aussen vor: sie gehoeren nicht zum
|
||||
* Vermoegen der betreuten Person.
|
||||
*/
|
||||
export function bestaendeErmitteln(
|
||||
konten: KontoEingabe[],
|
||||
von: Date,
|
||||
bis: Date,
|
||||
): Bestandszeile[] {
|
||||
const vortag = tagePlus(von, -1)
|
||||
|
||||
return konten
|
||||
.filter((konto) => !konto.istBetreuerkonto)
|
||||
.map((konto) => {
|
||||
const anfang = saldoZumStichtag(konto.saldostaende, vortag)
|
||||
const ende = saldoZumStichtag(konto.saldostaende, bis)
|
||||
return {
|
||||
kontoId: konto.id,
|
||||
bezeichnung: konto.bezeichnung,
|
||||
bank: konto.bank,
|
||||
art: konto.art,
|
||||
artBeschriftung: KONTO_ART_BESCHRIFTUNG[konto.art],
|
||||
ibanMaskiert: konto.ibanMaskiert ?? null,
|
||||
anfangCent: anfang?.betragCent ?? 0n,
|
||||
anfangStichtag: anfang ? alsTag(anfang.stichtag) : null,
|
||||
endeCent: ende?.betragCent ?? 0n,
|
||||
endeStichtag: ende ? alsTag(ende.stichtag) : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Belegliste
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type Belegzeile = {
|
||||
/** Laufende Nummer im Bericht, ab 1. Danach wird im Ordner sortiert. */
|
||||
nummer: number
|
||||
buchungId: string
|
||||
datum: Date
|
||||
betragCent: bigint
|
||||
richtung: BuchungEingabe['richtung']
|
||||
beschriftung: string
|
||||
gegenstelle: string | null
|
||||
verwendungszweck: string | null
|
||||
belegId: string | null
|
||||
belegTitel: string | null
|
||||
belegFehltBegruendung: string | null
|
||||
fehlt: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Laufende Belegnummern. Sortiert nach Datum - so laesst sich die
|
||||
* Belegmappe in derselben Reihenfolge abheften, in der die Zahlen im
|
||||
* Bericht stehen.
|
||||
*/
|
||||
export function beleglisteErstellen(buchungen: BuchungEingabe[]): Belegzeile[] {
|
||||
return nachTagSortieren(buchungen, (b) => b.datum).map((buchung, i) => ({
|
||||
nummer: i + 1,
|
||||
buchungId: buchung.id,
|
||||
datum: alsTag(buchung.datum),
|
||||
betragCent: buchung.betragCent,
|
||||
richtung: buchung.richtung,
|
||||
beschriftung: buchung.gegenstelle ?? buchung.verwendungszweck ?? 'ohne Bezeichnung',
|
||||
gegenstelle: buchung.gegenstelle,
|
||||
verwendungszweck: buchung.verwendungszweck,
|
||||
belegId: buchung.belegId,
|
||||
belegTitel: buchung.belegTitel ?? null,
|
||||
belegFehltBegruendung: buchung.belegFehltBegruendung,
|
||||
fehlt: belegFehlt(buchung),
|
||||
}))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Die Rechnung selbst
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type Rechenprobe = {
|
||||
anfangsbestandCent: bigint
|
||||
einnahmenCent: bigint
|
||||
ausgabenCent: bigint
|
||||
/** Anfangsbestand + Einnahmen - Ausgaben. */
|
||||
rechnerischerEndbestandCent: bigint
|
||||
/** Summe der Saldostaende zum Ende des Zeitraums. */
|
||||
gebuchterEndbestandCent: bigint
|
||||
/** gebucht - rechnerisch. Positiv: es ist mehr da, als die Buchungen erklären. */
|
||||
differenzCent: bigint
|
||||
stimmt: boolean
|
||||
}
|
||||
|
||||
export type Rechnungsdaten = {
|
||||
von: Date
|
||||
bis: Date
|
||||
variante: Variante
|
||||
/** Gesetzt, wenn der Zeitraum einem Betreuungsjahr entspricht. */
|
||||
betreuungsjahr: Betreuungsjahr | null
|
||||
/** Ein noch laufendes Jahr ergibt eine Zwischenaufstellung, keine Schlussrechnung. */
|
||||
zwischenaufstellung: boolean
|
||||
|
||||
bestaende: Bestandszeile[]
|
||||
anfangsbestandCent: bigint
|
||||
endbestandCent: bigint
|
||||
|
||||
einnahmen: KategorieGruppe[]
|
||||
ausgaben: KategorieGruppe[]
|
||||
summeEinnahmenCent: bigint
|
||||
summeAusgabenCent: bigint
|
||||
vermoegenswirksamEinnahmenCent: bigint
|
||||
vermoegenswirksamAusgabenCent: bigint
|
||||
|
||||
umbuchungen: BuchungEingabe[]
|
||||
eigeneAuslagen: BuchungEingabe[]
|
||||
eigeneAuslagenCent: bigint
|
||||
|
||||
probe: Rechenprobe
|
||||
belege: Belegzeile[]
|
||||
beleg: Belegquote
|
||||
/** Klartexthinweise fuer den Bericht - jede Luecke wird benannt. */
|
||||
hinweise: string[]
|
||||
}
|
||||
|
||||
export function rechnungslegungBerechnen(eingabe: {
|
||||
von: Date
|
||||
bis: Date
|
||||
variante: Variante
|
||||
konten: KontoEingabe[]
|
||||
buchungen: BuchungEingabe[]
|
||||
betreuungsjahr?: Betreuungsjahr | null
|
||||
/** Fuer die Frage, ob das Jahr schon abgeschlossen ist. */
|
||||
heute?: Date
|
||||
}): Rechnungsdaten {
|
||||
const von = alsTag(eingabe.von)
|
||||
const bis = alsTag(eingabe.bis)
|
||||
const vortag = tagePlus(von, -1)
|
||||
|
||||
const imZeitraumListe = eingabe.buchungen.filter((b) => imZeitraum(b.datum, von, bis))
|
||||
|
||||
const bestaende = bestaendeErmitteln(eingabe.konten, von, bis)
|
||||
const anfangsbestandCent = summe(bestaende.map((b) => b.anfangCent))
|
||||
const endbestandCent = summe(bestaende.map((b) => b.endeCent))
|
||||
|
||||
const einnahmen = einnahmenGruppiert(imZeitraumListe)
|
||||
const ausgaben = ausgabenGruppiert(imZeitraumListe)
|
||||
const summeEinnahmenCent = summe(einnahmen.map((g) => g.summeCent))
|
||||
const summeAusgabenCent = summe(ausgaben.map((g) => g.summeCent))
|
||||
|
||||
const rechnerischerEndbestandCent = anfangsbestandCent + summeEinnahmenCent - summeAusgabenCent
|
||||
const differenzCent = endbestandCent - rechnerischerEndbestandCent
|
||||
|
||||
const relevante = imZeitraumListe.filter(istVermoegensbuchung)
|
||||
const eigeneAuslagen = imZeitraumListe.filter((b) => !b.betrifftBetreute)
|
||||
|
||||
const hinweise: string[] = []
|
||||
for (const zeile of bestaende) {
|
||||
if (zeile.anfangStichtag === null) {
|
||||
hinweise.push(
|
||||
`Für „${zeile.bezeichnung}“ gibt es keinen Saldostand zum ${formatKurz(vortag)} oder davor. ` +
|
||||
`Der Anfangsbestand wurde mit 0,00 € angesetzt.`,
|
||||
)
|
||||
} else if (zeile.anfangStichtag.getTime() !== vortag.getTime()) {
|
||||
hinweise.push(
|
||||
`Der Anfangsbestand von „${zeile.bezeichnung}“ stammt vom ${formatKurz(zeile.anfangStichtag)} ` +
|
||||
`statt vom ${formatKurz(vortag)}. Buchungen dazwischen sind in der Rechenprobe nicht gedeckt.`,
|
||||
)
|
||||
}
|
||||
if (zeile.endeStichtag === null) {
|
||||
hinweise.push(
|
||||
`Für „${zeile.bezeichnung}“ gibt es keinen Saldostand zum ${formatKurz(bis)} oder davor. ` +
|
||||
`Der Endbestand wurde mit 0,00 € angesetzt.`,
|
||||
)
|
||||
} else if (zeile.endeStichtag.getTime() !== bis.getTime()) {
|
||||
hinweise.push(
|
||||
`Der Endbestand von „${zeile.bezeichnung}“ stammt vom ${formatKurz(zeile.endeStichtag)} ` +
|
||||
`statt vom ${formatKurz(bis)}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (differenzCent !== 0n) {
|
||||
hinweise.push(
|
||||
'Die Rechenprobe geht nicht auf: Anfangsbestand zuzüglich Einnahmen abzüglich Ausgaben ' +
|
||||
'ergibt nicht den Endbestand. Entweder fehlen Buchungen, oder ein Saldostand ist nicht ' +
|
||||
'erfasst. Die Differenz ist im Bericht ausgewiesen und sollte vor der Abgabe geklärt werden.',
|
||||
)
|
||||
}
|
||||
|
||||
const fehlendeBelege = relevante.filter(belegFehlt).length
|
||||
if (fehlendeBelege > 0) {
|
||||
hinweise.push(
|
||||
`${fehlendeBelege} Buchung(en) haben weder einen Beleg noch eine Begründung, warum er fehlt.`,
|
||||
)
|
||||
}
|
||||
|
||||
const vermischt = imZeitraumListe.filter((b) => b.aufBetreuerkonto && b.betrifftBetreute).length
|
||||
if (vermischt > 0) {
|
||||
hinweise.push(
|
||||
`${vermischt} Buchung(en) laufen über ein Konto des Betreuers und sind trotzdem als ` +
|
||||
'Vermögen der betreuten Person gekennzeichnet. Das ist zu klären, bevor der Bericht abgeht.',
|
||||
)
|
||||
}
|
||||
|
||||
const jahr = eingabe.betreuungsjahr ?? null
|
||||
const stichtagHeute = eingabe.heute ? alsTag(eingabe.heute) : null
|
||||
const zwischenaufstellung =
|
||||
stichtagHeute !== null && stichtagHeute.getTime() < bis.getTime()
|
||||
|
||||
return {
|
||||
von,
|
||||
bis,
|
||||
variante: eingabe.variante,
|
||||
betreuungsjahr: jahr,
|
||||
zwischenaufstellung,
|
||||
bestaende,
|
||||
anfangsbestandCent,
|
||||
endbestandCent,
|
||||
einnahmen,
|
||||
ausgaben,
|
||||
summeEinnahmenCent,
|
||||
summeAusgabenCent,
|
||||
vermoegenswirksamEinnahmenCent: summe(einnahmen.map((g) => g.davonVermoegenswirksamCent)),
|
||||
vermoegenswirksamAusgabenCent: summe(ausgaben.map((g) => g.davonVermoegenswirksamCent)),
|
||||
umbuchungen: imZeitraumListe.filter((b) => b.richtung === 'UMBUCHUNG' && b.betrifftBetreute),
|
||||
eigeneAuslagen,
|
||||
eigeneAuslagenCent: summe(eigeneAuslagen.map((b) => b.betragCent)),
|
||||
probe: {
|
||||
anfangsbestandCent,
|
||||
einnahmenCent: summeEinnahmenCent,
|
||||
ausgabenCent: summeAusgabenCent,
|
||||
rechnerischerEndbestandCent,
|
||||
gebuchterEndbestandCent: endbestandCent,
|
||||
differenzCent,
|
||||
stimmt: differenzCent === 0n,
|
||||
},
|
||||
belege: beleglisteErstellen(relevante),
|
||||
beleg: belegquote(relevante),
|
||||
hinweise,
|
||||
}
|
||||
}
|
||||
|
||||
function formatKurz(datum: Date): string {
|
||||
const tag = String(datum.getUTCDate()).padStart(2, '0')
|
||||
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
|
||||
return `${tag}.${monat}.${datum.getUTCFullYear()}`
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { VermoegensArt } from '@prisma/client'
|
||||
import { summe } from '@/lib/finanzen/betrag'
|
||||
import { alsTag, nachTagSortieren, tageZwischen, vorOderGleich } from '@/lib/finanzen/datum'
|
||||
import { AKTIVA_ARTEN, istPassiv } from '@/lib/finanzen/kategorien'
|
||||
|
||||
/**
|
||||
* Vermoegen zu einem Stichtag.
|
||||
*
|
||||
* Diese Datei ist das Herz beider Berichte. Das Vermoegensverzeichnis
|
||||
* nach § 1835 BGB und die Vermoegensuebersicht nach § 1859 BGB fragen
|
||||
* dasselbe: "Was war an diesem Tag da?" Beantwortet wird das nie aus
|
||||
* einem aktuellen Wertfeld, sondern immer aus der Wertstand-Historie -
|
||||
* je Posten der juengste Wertstand, der nicht nach dem Stichtag liegt.
|
||||
*
|
||||
* Der Grund ist praktisch: Wenn das Gericht zwei Jahre spaeter nachfragt,
|
||||
* warum im Verzeichnis 42.000 EUR standen, muss genau dieselbe Zahl
|
||||
* wieder herauskommen. Wuerden Werte ueberschrieben, waere das nicht
|
||||
* mehr moeglich - dann stuende Aussage gegen Aussage.
|
||||
*
|
||||
* Ein Wertstand, der nach dem Stichtag liegt, wird nicht benutzt. Auch
|
||||
* dann nicht, wenn er "besser" waere. Rueckwaerts wissen gilt nicht.
|
||||
*/
|
||||
|
||||
export type WertstandEingabe = {
|
||||
stichtag: Date
|
||||
wertCent: bigint
|
||||
wertquelle: string
|
||||
nachweisDokumentId?: string | null
|
||||
bemerkung?: string | null
|
||||
}
|
||||
|
||||
export type PostenEingabe = {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
art: VermoegensArt
|
||||
wertstaende: WertstandEingabe[]
|
||||
/** Optionale Verweise, damit der Bericht auf das Objekt zeigen kann. */
|
||||
kontoId?: string | null
|
||||
immobilieId?: string | null
|
||||
vertragId?: string | null
|
||||
bewertungSozialhilfe?: string | null
|
||||
}
|
||||
|
||||
export type PostenZumStichtag = {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
art: VermoegensArt
|
||||
wertCent: bigint
|
||||
/** Stichtag des tatsaechlich verwendeten Wertstands. */
|
||||
bewertetAm: Date
|
||||
wertquelle: string
|
||||
nachweisDokumentId: string | null
|
||||
/** Wie alt ist der verwendete Wert am Stichtag? Null Tage = taggenau. */
|
||||
alterTage: number
|
||||
kontoId: string | null
|
||||
immobilieId: string | null
|
||||
vertragId: string | null
|
||||
bewertungSozialhilfe: string | null
|
||||
}
|
||||
|
||||
export type PostenOhneWert = {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
art: VermoegensArt
|
||||
/** Gibt es ueberhaupt Wertstaende, nur eben spaetere? */
|
||||
spaetereWertstaende: number
|
||||
}
|
||||
|
||||
export type Vermoegensgruppe = {
|
||||
art: VermoegensArt
|
||||
posten: PostenZumStichtag[]
|
||||
summeCent: bigint
|
||||
}
|
||||
|
||||
export type Vermoegensbild = {
|
||||
stichtag: Date
|
||||
/** Alles ausser VERBINDLICHKEIT, nach Gruppen in Berichtsreihenfolge. */
|
||||
aktivaGruppen: Vermoegensgruppe[]
|
||||
aktiva: PostenZumStichtag[]
|
||||
passiva: PostenZumStichtag[]
|
||||
/** Posten, fuer die es am Stichtag keinen Wert gibt - stehen im Bericht als Luecke. */
|
||||
ohneWert: PostenOhneWert[]
|
||||
summeAktivaCent: bigint
|
||||
summePassivaCent: bigint
|
||||
/** Aktiva minus Passiva. Das ist die Zahl, nach der das Gericht fragt. */
|
||||
reinvermoegenCent: bigint
|
||||
/** Aeltester verwendeter Wert - Hinweis darauf, dass nachbewertet werden sollte. */
|
||||
aeltesterWertTage: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Der juengste Wertstand, der nicht nach dem Stichtag liegt.
|
||||
* Null, wenn es zu diesem Tag noch keine Bewertung gab.
|
||||
*/
|
||||
export function wertZumStichtag(
|
||||
wertstaende: WertstandEingabe[],
|
||||
stichtag: Date,
|
||||
): WertstandEingabe | null {
|
||||
let treffer: WertstandEingabe | null = null
|
||||
for (const wert of wertstaende) {
|
||||
if (!vorOderGleich(wert.stichtag, stichtag)) continue
|
||||
if (treffer === null || alsTag(wert.stichtag) > alsTag(treffer.stichtag)) {
|
||||
treffer = wert
|
||||
}
|
||||
}
|
||||
return treffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Rechnung ohne Datenbank - hier laesst sich der Fall
|
||||
* "mehrere Wertstaende, Stichtag mittendrin" pruefen, ohne Postgres zu
|
||||
* starten.
|
||||
*/
|
||||
export function vermoegenBerechnen(posten: PostenEingabe[], stichtag: Date): Vermoegensbild {
|
||||
const tag = alsTag(stichtag)
|
||||
|
||||
const bewertet: PostenZumStichtag[] = []
|
||||
const ohneWert: PostenOhneWert[] = []
|
||||
|
||||
for (const p of posten) {
|
||||
const wert = wertZumStichtag(p.wertstaende, tag)
|
||||
if (!wert) {
|
||||
ohneWert.push({
|
||||
id: p.id,
|
||||
bezeichnung: p.bezeichnung,
|
||||
art: p.art,
|
||||
spaetereWertstaende: p.wertstaende.length,
|
||||
})
|
||||
continue
|
||||
}
|
||||
bewertet.push({
|
||||
id: p.id,
|
||||
bezeichnung: p.bezeichnung,
|
||||
art: p.art,
|
||||
wertCent: wert.wertCent,
|
||||
bewertetAm: alsTag(wert.stichtag),
|
||||
wertquelle: wert.wertquelle,
|
||||
nachweisDokumentId: wert.nachweisDokumentId ?? null,
|
||||
alterTage: tageZwischen(wert.stichtag, tag),
|
||||
kontoId: p.kontoId ?? null,
|
||||
immobilieId: p.immobilieId ?? null,
|
||||
vertragId: p.vertragId ?? null,
|
||||
bewertungSozialhilfe: p.bewertungSozialhilfe ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const aktiva = bewertet.filter((p) => !istPassiv(p.art))
|
||||
const passiva = bewertet.filter((p) => istPassiv(p.art))
|
||||
|
||||
const aktivaGruppen: Vermoegensgruppe[] = AKTIVA_ARTEN.map((art) => {
|
||||
const gruppe = aktiva
|
||||
.filter((p) => p.art === art)
|
||||
.sort((a, b) => a.bezeichnung.localeCompare(b.bezeichnung, 'de'))
|
||||
return { art, posten: gruppe, summeCent: summe(gruppe.map((p) => p.wertCent)) }
|
||||
}).filter((gruppe) => gruppe.posten.length > 0)
|
||||
|
||||
const summeAktivaCent = summe(aktiva.map((p) => p.wertCent))
|
||||
const summePassivaCent = summe(passiva.map((p) => p.wertCent))
|
||||
|
||||
const alter = bewertet.map((p) => p.alterTage)
|
||||
|
||||
return {
|
||||
stichtag: tag,
|
||||
aktivaGruppen,
|
||||
aktiva,
|
||||
passiva: passiva.sort((a, b) => a.bezeichnung.localeCompare(b.bezeichnung, 'de')),
|
||||
ohneWert,
|
||||
summeAktivaCent,
|
||||
summePassivaCent,
|
||||
reinvermoegenCent: summeAktivaCent - summePassivaCent,
|
||||
aeltesterWertTage: alter.length > 0 ? Math.max(...alter) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dieselbe Rechnung, aber mit den Daten aus der Datenbank.
|
||||
*
|
||||
* Der Zugriff auf db geschieht bewusst erst im Funktionsrumpf: so bleibt
|
||||
* dieses Modul fuer die Tests ohne Datenbank ladbar, und die reine
|
||||
* Rechnung oben ist unabhaengig pruefbar.
|
||||
*/
|
||||
export async function vermoegenZumStichtag(
|
||||
betreuteId: string,
|
||||
stichtag: Date,
|
||||
): Promise<Vermoegensbild> {
|
||||
const { db, nichtGeloescht } = await import('@/lib/db')
|
||||
|
||||
const posten = await db.vermoegensposten.findMany({
|
||||
where: { betreuteId, ...nichtGeloescht },
|
||||
include: { wertstaende: { orderBy: { stichtag: 'asc' } } },
|
||||
orderBy: [{ art: 'asc' }, { bezeichnung: 'asc' }],
|
||||
})
|
||||
|
||||
return vermoegenBerechnen(
|
||||
posten.map((p) => ({
|
||||
id: p.id,
|
||||
bezeichnung: p.bezeichnung,
|
||||
art: p.art,
|
||||
kontoId: p.kontoId,
|
||||
immobilieId: p.immobilieId,
|
||||
vertragId: p.vertragId,
|
||||
bewertungSozialhilfe: p.bewertungSozialhilfe,
|
||||
wertstaende: p.wertstaende.map((w) => ({
|
||||
stichtag: w.stichtag,
|
||||
wertCent: w.wertCent,
|
||||
wertquelle: w.wertquelle,
|
||||
nachweisDokumentId: w.nachweisDokumentId,
|
||||
bemerkung: w.bemerkung,
|
||||
})),
|
||||
})),
|
||||
stichtag,
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Wertverlauf eines einzelnen Postens
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type Verlaufspunkt = {
|
||||
stichtag: Date
|
||||
wertCent: bigint
|
||||
wertquelle: string
|
||||
/** Veraenderung gegenueber dem vorherigen Wertstand. */
|
||||
veraenderungCent: bigint | null
|
||||
}
|
||||
|
||||
/** Aeltester Wert zuerst, mit der Veraenderung zum Vorgaenger. */
|
||||
export function wertverlauf(wertstaende: WertstandEingabe[]): Verlaufspunkt[] {
|
||||
const sortiert = nachTagSortieren(wertstaende, (w) => w.stichtag)
|
||||
return sortiert.map((wert, i) => {
|
||||
const vorher = i > 0 ? sortiert[i - 1]! : null
|
||||
return {
|
||||
stichtag: alsTag(wert.stichtag),
|
||||
wertCent: wert.wertCent,
|
||||
wertquelle: wert.wertquelle,
|
||||
veraenderungCent: vorher ? wert.wertCent - vorher.wertCent : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Punkte fuer eine kleine Inline-SVG-Kurve, normiert auf ein Rechteck.
|
||||
* Bewusst kein Diagrammpaket: eine Handvoll Stuetzstellen als Polyline
|
||||
* reicht, laedt nichts nach und druckt sauber.
|
||||
*/
|
||||
export function verlaufsPunkte(
|
||||
verlauf: Verlaufspunkt[],
|
||||
breite: number,
|
||||
hoehe: number,
|
||||
): { x: number; y: number }[] {
|
||||
if (verlauf.length === 0) return []
|
||||
if (verlauf.length === 1) return [{ x: breite / 2, y: hoehe / 2 }]
|
||||
|
||||
const zeiten = verlauf.map((p) => p.stichtag.getTime())
|
||||
const werte = verlauf.map((p) => Number(p.wertCent))
|
||||
const tMin = Math.min(...zeiten)
|
||||
const tMax = Math.max(...zeiten)
|
||||
const wMin = Math.min(...werte)
|
||||
const wMax = Math.max(...werte)
|
||||
|
||||
const tSpanne = tMax - tMin || 1
|
||||
const wSpanne = wMax - wMin || 1
|
||||
|
||||
return verlauf.map((punkt, i) => ({
|
||||
x: ((zeiten[i]! - tMin) / tSpanne) * breite,
|
||||
// y ist in SVG nach unten gerichtet - deshalb gespiegelt.
|
||||
y: hoehe - ((werte[i]! - wMin) / wSpanne) * hoehe,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user