Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert. README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind US-Unternehmen und unterliegen auch bei EU-Region potenziell dem CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person ist das eine Abwaegung, die man bewusst treffen muss. Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon steht. Die Datenbank hat keine Portfreigabe. Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt dauerhaft stehen. Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern auch der Schluessel. Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie sollen der betreuten Person vorgelesen werden koennen. Die Seite /datenschutz liest denselben Text aus docs/, damit es nicht zwei Fassungen gibt, die auseinanderlaufen. Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der Server in UTC und verschiebt Fristen um einen Tag. Etappe: 1 Phase: Fundament
863 lines
31 KiB
TypeScript
863 lines
31 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
betragFormatieren,
|
||
betragParsen,
|
||
centAufteilen,
|
||
summe,
|
||
teileGerundet,
|
||
} from '@/lib/finanzen/betrag'
|
||
import {
|
||
ibanFormatieren,
|
||
ibanGueltig,
|
||
ibanMaskieren,
|
||
ibanNormalisieren,
|
||
ibanPruefen,
|
||
} from '@/lib/finanzen/iban'
|
||
import {
|
||
csvErkennen,
|
||
csvZerlegen,
|
||
csvZuBuchungen,
|
||
datumParsen,
|
||
dublettenMarkieren,
|
||
importReferenzBilden,
|
||
trennzeichenErkennen,
|
||
} from '@/lib/finanzen/import'
|
||
import { camtZuBuchungen, dezimalZuCent } from '@/lib/finanzen/camt'
|
||
import {
|
||
betreuungsjahr,
|
||
betreuungsjahre,
|
||
betreuungsjahrZuDatum,
|
||
aufwandspauschaleBis,
|
||
} from '@/lib/finanzen/betreuungsjahr'
|
||
import { vermoegenBerechnen, wertverlauf, wertZumStichtag } from '@/lib/finanzen/vermoegen'
|
||
import {
|
||
belegquote,
|
||
laufendeBewegungen,
|
||
vermischungsWarnung,
|
||
type BuchungEingabe,
|
||
} from '@/lib/finanzen/kassenbuch'
|
||
import {
|
||
rechnungslegungBerechnen,
|
||
saldoZumStichtag,
|
||
type KontoEingabe,
|
||
} from '@/lib/finanzen/rechnungslegung'
|
||
import { tagAusText } from '@/lib/finanzen/datum'
|
||
|
||
/**
|
||
* Fachliche Pruefungen des Finanzmoduls - ohne Datenbank.
|
||
*
|
||
* Jeder Test beschreibt, was fachlich gelten soll. Wenn einer davon rot
|
||
* wird, stimmt eine Zahl im Vermoegensverzeichnis oder in der
|
||
* Rechnungslegung nicht mehr - und die gehen ans Betreuungsgericht.
|
||
*/
|
||
|
||
const tag = (text: string): Date => {
|
||
const datum = tagAusText(text)
|
||
if (!datum) throw new Error(`Testdatum ${text} ist ungültig.`)
|
||
return datum
|
||
}
|
||
|
||
// =============================================================================
|
||
// IBAN
|
||
// =============================================================================
|
||
|
||
describe('IBAN-Prüfziffer nach ISO 13616', () => {
|
||
const gueltige = [
|
||
'DE89 3704 0044 0532 0130 00',
|
||
'DE02120300000000202051',
|
||
'AT61 1904 3002 3457 3201',
|
||
'CH93 0076 2011 6238 5295 7',
|
||
'GB82 WEST 1234 5698 7654 32',
|
||
'FR14 2004 1010 0505 0001 3M02 606',
|
||
'NL91 ABNA 0417 1643 00',
|
||
'BE68 5390 0754 7034',
|
||
'ES91 2100 0418 4502 0005 1332',
|
||
'IT60 X054 2811 1010 0000 0123 456',
|
||
]
|
||
|
||
it.each(gueltige)('erkennt %s als gültig', (iban) => {
|
||
const pruefung = ibanPruefen(iban)
|
||
expect(pruefung.gueltig, JSON.stringify(pruefung)).toBe(true)
|
||
})
|
||
|
||
it('weist einen Zahlendreher zurück – auch wenn die Länge stimmt', () => {
|
||
// Aus DE89370400440532013000 wurden zwei Ziffern vertauscht.
|
||
const pruefung = ibanPruefen('DE89 3704 0044 0532 0103 00')
|
||
expect(pruefung.gueltig).toBe(false)
|
||
if (!pruefung.gueltig) expect(pruefung.meldung).toMatch(/Prüfziffer/)
|
||
})
|
||
|
||
it('weist eine falsche Prüfziffer zurück', () => {
|
||
expect(ibanGueltig('DE88 3704 0044 0532 0130 00')).toBe(false)
|
||
})
|
||
|
||
it('weist eine zu kurze deutsche IBAN zurück', () => {
|
||
const pruefung = ibanPruefen('DE89 3704 0044 0532 0130')
|
||
expect(pruefung.gueltig).toBe(false)
|
||
if (!pruefung.gueltig) expect(pruefung.meldung).toMatch(/22 Zeichen/)
|
||
})
|
||
|
||
it('weist ein unbekanntes Länderkürzel zurück', () => {
|
||
const pruefung = ibanPruefen('ZZ89370400440532013000')
|
||
expect(pruefung.gueltig).toBe(false)
|
||
if (!pruefung.gueltig) expect(pruefung.meldung).toMatch(/Länderkürzel/)
|
||
})
|
||
|
||
it('nimmt Leerzeichen, Kleinschreibung und Bindestriche entgegen', () => {
|
||
expect(ibanNormalisieren('de89-3704 0044 0532 0130 00')).toBe('DE89370400440532013000')
|
||
expect(ibanGueltig('de89-3704 0044 0532 0130 00')).toBe(true)
|
||
})
|
||
|
||
it('formatiert in Vierergruppen, wie auf dem Kontoauszug', () => {
|
||
expect(ibanFormatieren('DE89370400440532013000')).toBe('DE89 3704 0044 0532 0130 00')
|
||
})
|
||
|
||
it('maskiert für Listen, lässt Anfang und Ende stehen', () => {
|
||
// Vier Zeichen vorne, vier hinten - der Rest verschwindet.
|
||
expect(ibanMaskieren('DE89370400440532013000')).toBe('DE89 **** **** **** **30 00')
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// Beträge und Rundung
|
||
// =============================================================================
|
||
|
||
describe('Geldbeträge – nur Ganzzahlen in Cent', () => {
|
||
it('liest deutsche Schreibweisen', () => {
|
||
expect(betragParsen('1.234,56')).toBe(123456n)
|
||
expect(betragParsen('1234,56')).toBe(123456n)
|
||
expect(betragParsen('1234')).toBe(123400n)
|
||
expect(betragParsen('1234,5')).toBe(123450n)
|
||
expect(betragParsen('0,01')).toBe(1n)
|
||
expect(betragParsen('1.234.567,89')).toBe(123456789n)
|
||
expect(betragParsen('€ 1.234,56')).toBe(123456n)
|
||
expect(betragParsen('1 234,56')).toBe(123456n)
|
||
})
|
||
|
||
it('liest Vorzeichen vorne, hinten und in Klammern', () => {
|
||
expect(betragParsen('-1.234,56')).toBe(-123456n)
|
||
expect(betragParsen('1.234,56-')).toBe(-123456n)
|
||
expect(betragParsen('(1.234,56)')).toBe(-123456n)
|
||
expect(betragParsen('+12,00')).toBe(1200n)
|
||
})
|
||
|
||
it('versteht "1.234" deutsch als eintausendzweihundertvierunddreißig', () => {
|
||
expect(betragParsen('1.234')).toBe(123400n)
|
||
})
|
||
|
||
it('liest die englische Schreibweise mit Punkt als Dezimaltrennzeichen', () => {
|
||
expect(betragParsen('1234.56')).toBe(123456n)
|
||
expect(betragParsen('1,234.56')).toBe(123456n)
|
||
})
|
||
|
||
it('rundet nicht eigenmächtig, sondern lehnt drei Nachkommastellen ab', () => {
|
||
expect(() => betragParsen('1,005')).toThrow(/zwei Nachkommastellen/)
|
||
})
|
||
|
||
it('lehnt Unsinn ab', () => {
|
||
expect(() => betragParsen('')).toThrow()
|
||
expect(() => betragParsen('abc')).toThrow()
|
||
expect(() => betragParsen('1-2')).toThrow()
|
||
})
|
||
|
||
it('formatiert zurück in die deutsche Schreibweise', () => {
|
||
expect(betragFormatieren(123456n)).toBe('1.234,56')
|
||
expect(betragFormatieren(-5n)).toBe('-0,05')
|
||
expect(betragFormatieren(0n)).toBe('0,00')
|
||
expect(betragFormatieren(100000000n)).toBe('1.000.000,00')
|
||
})
|
||
|
||
it('rundet kaufmännisch, halbe Cent vom Nullpunkt weg', () => {
|
||
expect(teileGerundet(100n, 3n)).toBe(33n)
|
||
expect(teileGerundet(200n, 3n)).toBe(67n)
|
||
expect(teileGerundet(5n, 2n)).toBe(3n)
|
||
expect(teileGerundet(-5n, 2n)).toBe(-3n)
|
||
expect(teileGerundet(-100n, 3n)).toBe(-33n)
|
||
})
|
||
|
||
it('verliert beim Aufteilen keinen Cent', () => {
|
||
const teile = centAufteilen(100n, 3)
|
||
expect(teile).toEqual([34n, 33n, 33n])
|
||
expect(summe(teile)).toBe(100n)
|
||
|
||
const negativ = centAufteilen(-100n, 3)
|
||
expect(summe(negativ)).toBe(-100n)
|
||
|
||
for (const betrag of [1n, 7n, 99n, 100001n, 123456789n]) {
|
||
for (const anzahl of [1, 2, 3, 7, 12]) {
|
||
expect(summe(centAufteilen(betrag, anzahl))).toBe(betrag)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// CSV-Import
|
||
// =============================================================================
|
||
|
||
const SPARKASSE_CSV = [
|
||
'Auftragskonto;Buchungstag;Valutadatum;Buchungstext;Verwendungszweck;Beguenstigter/Zahlungspflichtiger;Kontonummer;BLZ;Betrag;Waehrung',
|
||
'DE89370400440532013000;15.01.2026;15.01.2026;LASTSCHRIFT;Heimkosten Januar 2026;Seniowohnpark Bayreuth;DE02120300000000202051;;-2.345,67;EUR',
|
||
'DE89370400440532013000;01.02.2026;01.02.2026;GUTSCHRIFT;Rente 02/2026;Deutsche Rentenversicherung;;;1.412,08;EUR',
|
||
'DE89370400440532013000;03.02.2026;03.02.2026;BARGELD;Barbetrag;;;;-50,00;EUR',
|
||
'DE89370400440532013000;03.02.2026;03.02.2026;BARGELD;Barbetrag;;;;-50,00;EUR',
|
||
].join('\r\n')
|
||
|
||
describe('CSV-Import', () => {
|
||
it('erkennt das Semikolon als Trennzeichen', () => {
|
||
expect(trennzeichenErkennen(SPARKASSE_CSV)).toBe(';')
|
||
})
|
||
|
||
it('schützt Trennzeichen in Anführungszeichen', () => {
|
||
const zeilen = csvZerlegen('a;"b;c";d\r\ne;"f""g";h', ';')
|
||
expect(zeilen).toEqual([
|
||
['a', 'b;c', 'd'],
|
||
['e', 'f"g', 'h'],
|
||
])
|
||
})
|
||
|
||
it('findet Kopfzeile und Spalten', () => {
|
||
const erkennung = csvErkennen(SPARKASSE_CSV)
|
||
expect(erkennung.trennzeichen).toBe(';')
|
||
expect(erkennung.kopfzeile[1]).toBe('Buchungstag')
|
||
expect(erkennung.zuordnung.datum).toBe(1)
|
||
expect(erkennung.zuordnung.wertstellung).toBe(2)
|
||
expect(erkennung.zuordnung.betrag).toBe(8)
|
||
expect(erkennung.zuordnung.waehrung).toBe(9)
|
||
expect(erkennung.zuordnung.gegenstelle).toBe(5)
|
||
expect(erkennung.zuordnung.verwendungszweck).toBe(4)
|
||
})
|
||
|
||
it('liest deutsche Zahlenformate und leitet die Richtung aus dem Vorzeichen ab', () => {
|
||
const ergebnis = csvZuBuchungen(SPARKASSE_CSV)
|
||
expect(ergebnis.fehler).toEqual([])
|
||
expect(ergebnis.zeilen).toHaveLength(4)
|
||
|
||
const heimkosten = ergebnis.zeilen[0]!
|
||
expect(heimkosten.betragCent).toBe(234567n)
|
||
expect(heimkosten.richtung).toBe('AUSGABE')
|
||
expect(heimkosten.datum.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||
expect(heimkosten.gegenstelle).toBe('Seniowohnpark Bayreuth')
|
||
expect(heimkosten.verwendungszweck).toContain('Heimkosten Januar 2026')
|
||
|
||
const rente = ergebnis.zeilen[1]!
|
||
expect(rente.betragCent).toBe(141208n)
|
||
expect(rente.richtung).toBe('EINNAHME')
|
||
})
|
||
|
||
it('überspringt Zeilen in fremder Währung, statt sie falsch umzurechnen', () => {
|
||
const mitDollar = SPARKASSE_CSV.replace(';1.412,08;EUR', ';1.412,08;USD')
|
||
const ergebnis = csvZuBuchungen(mitDollar)
|
||
expect(ergebnis.fremdwaehrung).toBe(1)
|
||
expect(ergebnis.zeilen).toHaveLength(3)
|
||
expect(ergebnis.fehler[0]!.meldung).toMatch(/USD/)
|
||
})
|
||
|
||
it('meldet ein unlesbares Datum als Zeilenfehler, statt die Datei abzubrechen', () => {
|
||
const kaputt = SPARKASSE_CSV.replace('15.01.2026;15.01.2026', 'irgendwann;15.01.2026')
|
||
const ergebnis = csvZuBuchungen(kaputt)
|
||
expect(ergebnis.zeilen).toHaveLength(3)
|
||
expect(ergebnis.fehler).toHaveLength(1)
|
||
expect(ergebnis.fehler[0]!.zeilennummer).toBe(2)
|
||
})
|
||
|
||
it('kommt mit getrennten Soll- und Haben-Spalten zurecht', () => {
|
||
const csv = [
|
||
'Datum;Soll;Haben;Verwendungszweck',
|
||
'15.01.2026;2.345,67;;Heimkosten',
|
||
'01.02.2026;;1.412,08;Rente',
|
||
].join('\n')
|
||
const ergebnis = csvZuBuchungen(csv)
|
||
expect(ergebnis.fehler).toEqual([])
|
||
expect(ergebnis.zeilen[0]!.richtung).toBe('AUSGABE')
|
||
expect(ergebnis.zeilen[0]!.betragCent).toBe(234567n)
|
||
expect(ergebnis.zeilen[1]!.richtung).toBe('EINNAHME')
|
||
})
|
||
|
||
it('lässt eine Soll/Haben-Spalte das Vorzeichen bestimmen', () => {
|
||
const csv = [
|
||
'Buchungstag;Betrag;Soll/Haben;Verwendungszweck',
|
||
'15.01.2026;2.345,67;S;Heimkosten',
|
||
'01.02.2026;1.412,08;H;Rente',
|
||
].join('\n')
|
||
const ergebnis = csvZuBuchungen(csv)
|
||
expect(ergebnis.fehler).toEqual([])
|
||
expect(ergebnis.zeilen[0]!.richtung).toBe('AUSGABE')
|
||
expect(ergebnis.zeilen[1]!.richtung).toBe('EINNAHME')
|
||
})
|
||
|
||
it('überspringt Vorspannzeilen und findet die Kopfzeile weiter unten', () => {
|
||
const csv = ['Kontoauszug Nr. 3', 'Zeitraum 01.01.2026 - 31.01.2026', '', SPARKASSE_CSV].join(
|
||
'\r\n',
|
||
)
|
||
const erkennung = csvErkennen(csv)
|
||
expect(erkennung.kopfzeileNummer).toBe(3)
|
||
})
|
||
|
||
it('liest alle gängigen Datumsformate', () => {
|
||
expect(datumParsen('15.01.2026')?.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||
expect(datumParsen('5.1.2026')?.toISOString().slice(0, 10)).toBe('2026-01-05')
|
||
expect(datumParsen('15.01.26')?.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||
expect(datumParsen('2026-01-15')?.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||
expect(datumParsen('15/01/2026')?.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||
expect(datumParsen('31.02.2026')).toBeNull()
|
||
expect(datumParsen('Montag')).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('Dublettenschutz beim Import', () => {
|
||
it('vergibt für zwei gleiche Buchungen am selben Tag verschiedene Referenzen', () => {
|
||
const ergebnis = csvZuBuchungen(SPARKASSE_CSV)
|
||
const referenzen = ergebnis.zeilen.map((z) => z.importReferenz)
|
||
expect(new Set(referenzen).size).toBe(referenzen.length)
|
||
// Die beiden Barabhebungen unterscheiden sich nur durch die laufende Nummer.
|
||
expect(referenzen[3]).toBe(`${referenzen[2]}#2`)
|
||
})
|
||
|
||
it('erzeugt für dieselbe Datei denselben Schlüssel – erneuter Import legt nichts doppelt an', () => {
|
||
const ersterLauf = csvZuBuchungen(SPARKASSE_CSV).zeilen.map((z) => z.importReferenz)
|
||
const zweiterLauf = csvZuBuchungen(SPARKASSE_CSV).zeilen.map((z) => z.importReferenz)
|
||
expect(zweiterLauf).toEqual(ersterLauf)
|
||
|
||
const markiert = dublettenMarkieren(csvZuBuchungen(SPARKASSE_CSV).zeilen, ersterLauf)
|
||
expect(markiert.dubletten).toBe(4)
|
||
expect(markiert.zeilen.every((z) => z.dublette)).toBe(true)
|
||
})
|
||
|
||
it('erkennt eine geänderte Buchung als neu', () => {
|
||
const referenz = importReferenzBilden({
|
||
datum: tag('2026-01-15'),
|
||
betragCent: 234567n,
|
||
richtung: 'AUSGABE',
|
||
gegenstelle: 'Heim',
|
||
verwendungszweck: 'Januar',
|
||
})
|
||
const andere = importReferenzBilden({
|
||
datum: tag('2026-01-15'),
|
||
betragCent: 234568n,
|
||
richtung: 'AUSGABE',
|
||
gegenstelle: 'Heim',
|
||
verwendungszweck: 'Januar',
|
||
})
|
||
expect(referenz).not.toBe(andere)
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// CAMT.053
|
||
// =============================================================================
|
||
|
||
const CAMT = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||
<BkToCstmrStmt>
|
||
<Stmt>
|
||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id><Ccy>EUR</Ccy></Acct>
|
||
<Ntry>
|
||
<Amt Ccy="EUR">2345.67</Amt>
|
||
<CdtDbtInd>DBIT</CdtDbtInd>
|
||
<BookgDt><Dt>2026-01-15</Dt></BookgDt>
|
||
<ValDt><Dt>2026-01-16</Dt></ValDt>
|
||
<NtryDtls><TxDtls>
|
||
<RltdPties><Cdtr><Nm>Seniowohnpark Bayreuth</Nm></Cdtr></RltdPties>
|
||
<RmtInf><Ustrd>Heimkosten Januar 2026</Ustrd></RmtInf>
|
||
</TxDtls></NtryDtls>
|
||
</Ntry>
|
||
<Ntry>
|
||
<Amt Ccy="EUR">1412.08</Amt>
|
||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||
<BookgDt><Dt>2026-02-01</Dt></BookgDt>
|
||
<NtryDtls><TxDtls>
|
||
<RltdPties><Dbtr><Nm>Deutsche Rentenversicherung</Nm></Dbtr></RltdPties>
|
||
<RmtInf><Ustrd>Rente 02/2026</Ustrd></RmtInf>
|
||
</TxDtls></NtryDtls>
|
||
</Ntry>
|
||
</Stmt>
|
||
</BkToCstmrStmt>
|
||
</Document>`
|
||
|
||
describe('CAMT.053', () => {
|
||
it('liest Betrag, Richtung, Datum und Gegenstelle', () => {
|
||
const ergebnis = camtZuBuchungen(CAMT)
|
||
expect(ergebnis.iban).toBe('DE89370400440532013000')
|
||
expect(ergebnis.fehler).toEqual([])
|
||
expect(ergebnis.zeilen).toHaveLength(2)
|
||
|
||
const belastung = ergebnis.zeilen[0]!
|
||
expect(belastung.betragCent).toBe(234567n)
|
||
expect(belastung.richtung).toBe('AUSGABE')
|
||
expect(belastung.gegenstelle).toBe('Seniowohnpark Bayreuth')
|
||
expect(belastung.wertstellung?.toISOString().slice(0, 10)).toBe('2026-01-16')
|
||
|
||
const gutschrift = ergebnis.zeilen[1]!
|
||
expect(gutschrift.richtung).toBe('EINNAHME')
|
||
expect(gutschrift.gegenstelle).toBe('Deutsche Rentenversicherung')
|
||
})
|
||
|
||
it('rechnet ISO-Beträge ohne Fließkomma in Cent', () => {
|
||
expect(dezimalZuCent('2345.67')).toBe(234567n)
|
||
expect(dezimalZuCent('0.01')).toBe(1n)
|
||
expect(dezimalZuCent('1000')).toBe(100000n)
|
||
expect(dezimalZuCent('-12.30')).toBe(-1230n)
|
||
expect(() => dezimalZuCent('1.234')).toThrow()
|
||
})
|
||
|
||
it('lehnt Dateien mit DOCTYPE ab', () => {
|
||
expect(() => camtZuBuchungen('<!DOCTYPE foo><Document/>')).toThrow(/DOCTYPE/)
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// Betreuungsjahr
|
||
// =============================================================================
|
||
|
||
describe('Betreuungsjahr – läuft ab der Bestellung, nicht ab dem 1. Januar', () => {
|
||
const bestellung = tag('2025-03-15')
|
||
|
||
it('beginnt am Tag der Bestellung und endet einen Tag vor dem Jahrestag', () => {
|
||
const erstes = betreuungsjahr(bestellung, 1)
|
||
expect(erstes.von.toISOString().slice(0, 10)).toBe('2025-03-15')
|
||
expect(erstes.bis.toISOString().slice(0, 10)).toBe('2026-03-14')
|
||
|
||
const drittes = betreuungsjahr(bestellung, 3)
|
||
expect(drittes.von.toISOString().slice(0, 10)).toBe('2027-03-15')
|
||
expect(drittes.bis.toISOString().slice(0, 10)).toBe('2028-03-14')
|
||
})
|
||
|
||
it('ordnet die Randtage dem richtigen Jahr zu', () => {
|
||
expect(betreuungsjahrZuDatum(bestellung, tag('2025-03-15'))?.nummer).toBe(1)
|
||
expect(betreuungsjahrZuDatum(bestellung, tag('2026-03-14'))?.nummer).toBe(1)
|
||
expect(betreuungsjahrZuDatum(bestellung, tag('2026-03-15'))?.nummer).toBe(2)
|
||
expect(betreuungsjahrZuDatum(bestellung, tag('2025-12-31'))?.nummer).toBe(1)
|
||
expect(betreuungsjahrZuDatum(bestellung, tag('2026-01-01'))?.nummer).toBe(1)
|
||
})
|
||
|
||
it('kennt kein Betreuungsjahr vor der Bestellung', () => {
|
||
expect(betreuungsjahrZuDatum(bestellung, tag('2025-03-14'))).toBeNull()
|
||
expect(betreuungsjahre(bestellung, tag('2024-01-01'))).toEqual([])
|
||
})
|
||
|
||
it('lässt bei einer Bestellung am 29. Februar weder Lücke noch Überschneidung', () => {
|
||
const schalttag = tag('2024-02-29')
|
||
const erstes = betreuungsjahr(schalttag, 1)
|
||
const zweites = betreuungsjahr(schalttag, 2)
|
||
|
||
expect(erstes.von.toISOString().slice(0, 10)).toBe('2024-02-29')
|
||
expect(erstes.bis.toISOString().slice(0, 10)).toBe('2025-02-27')
|
||
expect(zweites.von.toISOString().slice(0, 10)).toBe('2025-02-28')
|
||
|
||
// Kein Tag fällt durch: das Ende des ersten Jahres und der Beginn des
|
||
// zweiten liegen genau einen Tag auseinander.
|
||
const luecke =
|
||
(zweites.von.getTime() - erstes.bis.getTime()) / 86_400_000
|
||
expect(luecke).toBe(1)
|
||
|
||
expect(betreuungsjahrZuDatum(schalttag, tag('2025-02-27'))?.nummer).toBe(1)
|
||
expect(betreuungsjahrZuDatum(schalttag, tag('2025-02-28'))?.nummer).toBe(2)
|
||
})
|
||
|
||
it('zählt alle Jahre bis zum Stichtag auf', () => {
|
||
const jahre = betreuungsjahre(bestellung, tag('2027-06-30'))
|
||
expect(jahre.map((j) => j.nummer)).toEqual([1, 2, 3])
|
||
expect(jahre[2]!.bezeichnung).toContain('3. Betreuungsjahr')
|
||
})
|
||
|
||
it('setzt die Ausschlussfrist der Aufwandspauschale sechs Monate nach Jahresende', () => {
|
||
const erstes = betreuungsjahr(bestellung, 1)
|
||
expect(aufwandspauschaleBis(erstes).toISOString().slice(0, 10)).toBe('2026-09-14')
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// Vermögen zum Stichtag
|
||
// =============================================================================
|
||
|
||
const hausWertstaende = [
|
||
{ stichtag: tag('2025-03-31'), wertCent: 21000000n, wertquelle: 'Bodenrichtwert 2025' },
|
||
{ stichtag: tag('2026-01-15'), wertCent: 19500000n, wertquelle: 'Maklergutachten' },
|
||
{ stichtag: tag('2026-08-01'), wertCent: 18000000n, wertquelle: 'Kaufangebot' },
|
||
]
|
||
|
||
describe('Vermögen zum Stichtag – der jüngste Wert, der nicht in der Zukunft liegt', () => {
|
||
it('nimmt den passenden Wertstand, wenn der Stichtag zwischen zwei Bewertungen liegt', () => {
|
||
expect(wertZumStichtag(hausWertstaende, tag('2025-12-31'))?.wertCent).toBe(21000000n)
|
||
expect(wertZumStichtag(hausWertstaende, tag('2026-01-15'))?.wertCent).toBe(19500000n)
|
||
expect(wertZumStichtag(hausWertstaende, tag('2026-07-31'))?.wertCent).toBe(19500000n)
|
||
expect(wertZumStichtag(hausWertstaende, tag('2026-08-01'))?.wertCent).toBe(18000000n)
|
||
expect(wertZumStichtag(hausWertstaende, tag('2030-01-01'))?.wertCent).toBe(18000000n)
|
||
})
|
||
|
||
it('benutzt nie einen Wert aus der Zukunft', () => {
|
||
expect(wertZumStichtag(hausWertstaende, tag('2025-03-30'))).toBeNull()
|
||
})
|
||
|
||
it('trennt Aktiva und Passiva und bildet das Reinvermögen', () => {
|
||
const bild = vermoegenBerechnen(
|
||
[
|
||
{ id: 'haus', bezeichnung: 'Hausgrundstück', art: 'IMMOBILIE', wertstaende: hausWertstaende },
|
||
{
|
||
id: 'lv',
|
||
bezeichnung: 'Lebensversicherung',
|
||
art: 'VERSICHERUNG',
|
||
wertstaende: [
|
||
{ stichtag: tag('2025-06-30'), wertCent: 1234567n, wertquelle: 'Rückkaufswertmitteilung' },
|
||
{ stichtag: tag('2026-06-30'), wertCent: 1289900n, wertquelle: 'Rückkaufswertmitteilung' },
|
||
],
|
||
},
|
||
{
|
||
id: 'darlehen',
|
||
bezeichnung: 'Restdarlehen Sparkasse',
|
||
art: 'VERBINDLICHKEIT',
|
||
wertstaende: [
|
||
{ stichtag: tag('2025-12-31'), wertCent: 4500000n, wertquelle: 'Kontoauszug Darlehen' },
|
||
],
|
||
},
|
||
{
|
||
id: 'schmuck',
|
||
bezeichnung: 'Schmuck',
|
||
art: 'WERTGEGENSTAND',
|
||
wertstaende: [
|
||
{ stichtag: tag('2027-01-01'), wertCent: 80000n, wertquelle: 'Schätzung Juwelier' },
|
||
],
|
||
},
|
||
],
|
||
tag('2026-07-01'),
|
||
)
|
||
|
||
expect(bild.summeAktivaCent).toBe(19500000n + 1289900n)
|
||
expect(bild.summePassivaCent).toBe(4500000n)
|
||
expect(bild.reinvermoegenCent).toBe(19500000n + 1289900n - 4500000n)
|
||
|
||
// Der Schmuck wurde erst nach dem Stichtag bewertet - er taucht als
|
||
// Lücke auf, nicht mit einem erfundenen Wert.
|
||
expect(bild.ohneWert.map((p) => p.id)).toEqual(['schmuck'])
|
||
|
||
expect(bild.aktivaGruppen.map((g) => g.art)).toEqual(['IMMOBILIE', 'VERSICHERUNG'])
|
||
expect(summe(bild.aktivaGruppen.map((g) => g.summeCent))).toBe(bild.summeAktivaCent)
|
||
})
|
||
|
||
it('merkt sich, wie alt der verwendete Wert ist', () => {
|
||
const bild = vermoegenBerechnen(
|
||
[{ id: 'haus', bezeichnung: 'Haus', art: 'IMMOBILIE', wertstaende: hausWertstaende }],
|
||
tag('2026-07-31'),
|
||
)
|
||
expect(bild.aktiva[0]!.bewertetAm.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||
expect(bild.aktiva[0]!.alterTage).toBe(197)
|
||
expect(bild.aeltesterWertTage).toBe(197)
|
||
})
|
||
|
||
it('zeigt im Wertverlauf die Veränderung zum Vorgänger', () => {
|
||
const verlauf = wertverlauf(hausWertstaende)
|
||
expect(verlauf.map((p) => p.veraenderungCent)).toEqual([null, -1500000n, -1500000n])
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// Kassenbuch: Belegzwang und Vermischung
|
||
// =============================================================================
|
||
|
||
function buchung(teil: Partial<BuchungEingabe> & { id: string }): BuchungEingabe {
|
||
return {
|
||
datum: tag('2026-01-15'),
|
||
betragCent: 10000n,
|
||
richtung: 'AUSGABE',
|
||
kategorie: 'SONSTIGE_AUSGABE',
|
||
gegenstelle: null,
|
||
verwendungszweck: null,
|
||
kontoId: 'giro',
|
||
belegId: null,
|
||
belegFehltBegruendung: null,
|
||
betrifftBetreute: true,
|
||
vermoegenswirksam: false,
|
||
...teil,
|
||
}
|
||
}
|
||
|
||
describe('Belegzwang', () => {
|
||
it('zählt Buchungen ohne Beleg und ohne Begründung getrennt', () => {
|
||
const quote = belegquote([
|
||
buchung({ id: '1', belegId: 'dok-1' }),
|
||
buchung({ id: '2', belegFehltBegruendung: 'Quittung ging beim Umzug verloren' }),
|
||
buchung({ id: '3' }),
|
||
buchung({ id: '4' }),
|
||
])
|
||
expect(quote.gesamt).toBe(4)
|
||
expect(quote.mitBeleg).toBe(1)
|
||
expect(quote.begruendet).toBe(1)
|
||
expect(quote.ohneNachweis).toBe(2)
|
||
expect(quote.quoteProzent).toBe(50)
|
||
})
|
||
|
||
it('behandelt eine leere Kasse als vollständig belegt', () => {
|
||
expect(belegquote([]).quoteProzent).toBe(100)
|
||
})
|
||
})
|
||
|
||
describe('Trennung der Vermögensmassen', () => {
|
||
it('warnt, wenn eine Buchung auf einem Betreuerkonto das Vermögen der Betreuten betrifft', () => {
|
||
expect(vermischungsWarnung({ aufBetreuerkonto: true, betrifftBetreute: true })).toMatch(
|
||
/Vermögensmassen/,
|
||
)
|
||
})
|
||
|
||
it('warnt nicht bei einer eigenen Auslage auf dem eigenen Konto', () => {
|
||
expect(vermischungsWarnung({ aufBetreuerkonto: true, betrifftBetreute: false })).toBeNull()
|
||
expect(vermischungsWarnung({ aufBetreuerkonto: false, betrifftBetreute: true })).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('Laufende Einnahmen und Ausgaben', () => {
|
||
it('rechnet zwölf Monate auf den Monat herunter und lässt Einmaliges außen vor', () => {
|
||
const buchungen: BuchungEingabe[] = [
|
||
...Array.from({ length: 12 }, (_, i) =>
|
||
buchung({
|
||
id: `rente-${i}`,
|
||
datum: tag(`2026-${String(i + 1).padStart(2, '0')}-01`),
|
||
richtung: 'EINNAHME',
|
||
kategorie: 'RENTE',
|
||
betragCent: 141208n,
|
||
}),
|
||
),
|
||
buchung({
|
||
id: 'verkauf',
|
||
datum: tag('2026-05-01'),
|
||
richtung: 'EINNAHME',
|
||
kategorie: 'SONSTIGE_EINNAHME',
|
||
betragCent: 18000000n,
|
||
vermoegenswirksam: true,
|
||
}),
|
||
]
|
||
|
||
const bewegungen = laufendeBewegungen(buchungen, tag('2026-01-01'), tag('2026-12-31'))
|
||
expect(bewegungen.summeEinnahmenCent).toBe(141208n * 12n)
|
||
expect(bewegungen.monatlichEinnahmenCent).toBe(141208n)
|
||
expect(bewegungen.einnahmen).toHaveLength(1)
|
||
expect(bewegungen.einnahmen[0]!.kategorie).toBe('RENTE')
|
||
})
|
||
|
||
it('rechnet den Monatswert aus der Gesamtsumme, damit die Zeilen zur Summe passen', () => {
|
||
const buchungen = [
|
||
buchung({ id: 'a', richtung: 'AUSGABE', kategorie: 'PORTO_UND_KOPIEN', betragCent: 100n }),
|
||
buchung({ id: 'b', richtung: 'AUSGABE', kategorie: 'FAHRTKOSTEN', betragCent: 100n }),
|
||
]
|
||
const bewegungen = laufendeBewegungen(buchungen, tag('2026-01-01'), tag('2026-12-31'))
|
||
expect(bewegungen.summeAusgabenCent).toBe(200n)
|
||
expect(bewegungen.monatlichAusgabenCent).toBe(teileGerundet(200n, 12n))
|
||
})
|
||
})
|
||
|
||
// =============================================================================
|
||
// Rechnungslegung und Rechenprobe
|
||
// =============================================================================
|
||
|
||
const konten: KontoEingabe[] = [
|
||
{
|
||
id: 'giro',
|
||
bezeichnung: 'Girokonto Sparkasse',
|
||
bank: 'Sparkasse Bayreuth',
|
||
art: 'GIRO',
|
||
istBetreuerkonto: false,
|
||
saldostaende: [
|
||
{ stichtag: tag('2025-03-14'), betragCent: 500000n, quelle: 'Kontoauszug 3' },
|
||
{ stichtag: tag('2026-03-14'), betragCent: 447641n, quelle: 'Kontoauszug 15' },
|
||
],
|
||
},
|
||
{
|
||
id: 'privat',
|
||
bezeichnung: 'Eigenes Konto des Betreuers',
|
||
bank: null,
|
||
art: 'GIRO',
|
||
istBetreuerkonto: true,
|
||
saldostaende: [{ stichtag: tag('2025-03-14'), betragCent: 999900n, quelle: null }],
|
||
},
|
||
]
|
||
|
||
describe('Rechnungslegung nach §§ 1859, 1865 BGB', () => {
|
||
const bestellung = tag('2025-03-15')
|
||
const jahr = betreuungsjahr(bestellung, 1)
|
||
|
||
const buchungen: BuchungEingabe[] = [
|
||
buchung({
|
||
id: 'rente',
|
||
datum: tag('2025-04-01'),
|
||
richtung: 'EINNAHME',
|
||
kategorie: 'RENTE',
|
||
betragCent: 141208n,
|
||
belegId: 'dok-rente',
|
||
}),
|
||
buchung({
|
||
id: 'heim',
|
||
datum: tag('2025-04-05'),
|
||
richtung: 'AUSGABE',
|
||
kategorie: 'HEIMKOSTEN',
|
||
betragCent: 193567n,
|
||
belegId: 'dok-heim',
|
||
}),
|
||
buchung({
|
||
id: 'umbuchung',
|
||
datum: tag('2025-05-02'),
|
||
richtung: 'UMBUCHUNG',
|
||
kategorie: 'UMBUCHUNG',
|
||
betragCent: 100000n,
|
||
}),
|
||
buchung({
|
||
id: 'auslage',
|
||
datum: tag('2025-06-01'),
|
||
richtung: 'AUSGABE',
|
||
kategorie: 'FAHRTKOSTEN',
|
||
betragCent: 4200n,
|
||
betrifftBetreute: false,
|
||
kontoId: 'privat',
|
||
aufBetreuerkonto: true,
|
||
}),
|
||
buchung({
|
||
id: 'ausserhalb',
|
||
datum: tag('2026-04-01'),
|
||
richtung: 'EINNAHME',
|
||
kategorie: 'RENTE',
|
||
betragCent: 141208n,
|
||
}),
|
||
]
|
||
|
||
it('rechnet Anfangsbestand plus Einnahmen minus Ausgaben zum Endbestand', () => {
|
||
const daten = rechnungslegungBerechnen({
|
||
von: jahr.von,
|
||
bis: jahr.bis,
|
||
variante: 'UEBERSICHT_1859',
|
||
konten,
|
||
buchungen,
|
||
betreuungsjahr: jahr,
|
||
})
|
||
|
||
expect(daten.anfangsbestandCent).toBe(500000n)
|
||
expect(daten.summeEinnahmenCent).toBe(141208n)
|
||
expect(daten.summeAusgabenCent).toBe(193567n)
|
||
expect(daten.probe.rechnerischerEndbestandCent).toBe(500000n + 141208n - 193567n)
|
||
expect(daten.probe.gebuchterEndbestandCent).toBe(447641n)
|
||
expect(daten.probe.differenzCent).toBe(0n)
|
||
expect(daten.probe.stimmt).toBe(true)
|
||
})
|
||
|
||
it('lässt Umbuchungen zwischen eigenen Konten aus den Summen heraus', () => {
|
||
const daten = rechnungslegungBerechnen({
|
||
von: jahr.von,
|
||
bis: jahr.bis,
|
||
variante: 'VOLL_1865',
|
||
konten,
|
||
buchungen,
|
||
betreuungsjahr: jahr,
|
||
})
|
||
expect(daten.umbuchungen).toHaveLength(1)
|
||
expect(daten.summeEinnahmenCent).toBe(141208n)
|
||
expect(daten.summeAusgabenCent).toBe(193567n)
|
||
})
|
||
|
||
it('führt eigene Auslagen des Betreuers getrennt und nicht im Vermögen', () => {
|
||
const daten = rechnungslegungBerechnen({
|
||
von: jahr.von,
|
||
bis: jahr.bis,
|
||
variante: 'VOLL_1865',
|
||
konten,
|
||
buchungen,
|
||
betreuungsjahr: jahr,
|
||
})
|
||
expect(daten.eigeneAuslagenCent).toBe(4200n)
|
||
expect(daten.belege.map((b) => b.buchungId)).not.toContain('auslage')
|
||
// Das Konto des Betreuers steht nicht in den Beständen der Betreuten.
|
||
expect(daten.bestaende.map((b) => b.kontoId)).toEqual(['giro'])
|
||
})
|
||
|
||
it('lässt Buchungen außerhalb des Betreuungsjahres weg', () => {
|
||
const daten = rechnungslegungBerechnen({
|
||
von: jahr.von,
|
||
bis: jahr.bis,
|
||
variante: 'VOLL_1865',
|
||
konten,
|
||
buchungen,
|
||
betreuungsjahr: jahr,
|
||
})
|
||
expect(daten.belege.map((b) => b.buchungId)).not.toContain('ausserhalb')
|
||
})
|
||
|
||
it('weist eine Differenz aus, statt sie stillschweigend glattzuziehen', () => {
|
||
const daten = rechnungslegungBerechnen({
|
||
von: jahr.von,
|
||
bis: jahr.bis,
|
||
variante: 'UEBERSICHT_1859',
|
||
konten,
|
||
// Die Heimkostenbuchung fehlt - der Endbestand ist trotzdem gebucht.
|
||
buchungen: buchungen.filter((b) => b.id !== 'heim'),
|
||
betreuungsjahr: jahr,
|
||
})
|
||
|
||
expect(daten.probe.stimmt).toBe(false)
|
||
expect(daten.probe.differenzCent).toBe(-193567n)
|
||
expect(daten.hinweise.some((h) => h.includes('Rechenprobe geht nicht auf'))).toBe(true)
|
||
})
|
||
|
||
it('benennt fehlende Saldostände, statt mit null zu rechnen und zu schweigen', () => {
|
||
const ohneSalden: KontoEingabe[] = [
|
||
{ ...konten[0]!, saldostaende: [] },
|
||
]
|
||
const daten = rechnungslegungBerechnen({
|
||
von: jahr.von,
|
||
bis: jahr.bis,
|
||
variante: 'UEBERSICHT_1859',
|
||
konten: ohneSalden,
|
||
buchungen,
|
||
betreuungsjahr: jahr,
|
||
})
|
||
expect(daten.anfangsbestandCent).toBe(0n)
|
||
expect(daten.hinweise.some((h) => h.includes('keinen Saldostand'))).toBe(true)
|
||
})
|
||
|
||
it('nimmt als Anfangsbestand den Stand am Tag vor Beginn des Jahres', () => {
|
||
expect(saldoZumStichtag(konten[0]!.saldostaende, tag('2025-03-14'))?.betragCent).toBe(500000n)
|
||
expect(saldoZumStichtag(konten[0]!.saldostaende, tag('2025-03-13'))).toBeNull()
|
||
})
|
||
|
||
it('vergibt laufende Belegnummern in Datumsreihenfolge', () => {
|
||
const daten = rechnungslegungBerechnen({
|
||
von: jahr.von,
|
||
bis: jahr.bis,
|
||
variante: 'VOLL_1865',
|
||
konten,
|
||
buchungen,
|
||
betreuungsjahr: jahr,
|
||
})
|
||
expect(daten.belege.map((b) => b.nummer)).toEqual([1, 2])
|
||
expect(daten.belege[0]!.buchungId).toBe('rente')
|
||
expect(daten.belege[1]!.buchungId).toBe('heim')
|
||
})
|
||
|
||
it('verliert bei der Gruppierung keinen Cent', () => {
|
||
const viele: BuchungEingabe[] = Array.from({ length: 37 }, (_, i) =>
|
||
buchung({
|
||
id: `x-${i}`,
|
||
datum: tag('2025-07-01'),
|
||
richtung: i % 2 === 0 ? 'EINNAHME' : 'AUSGABE',
|
||
kategorie: i % 2 === 0 ? 'ZINSEN' : 'BANKGEBUEHREN',
|
||
betragCent: BigInt(i * 7 + 1),
|
||
}),
|
||
)
|
||
const daten = rechnungslegungBerechnen({
|
||
von: jahr.von,
|
||
bis: jahr.bis,
|
||
variante: 'VOLL_1865',
|
||
konten,
|
||
buchungen: viele,
|
||
betreuungsjahr: jahr,
|
||
})
|
||
|
||
const einnahmenErwartet = summe(
|
||
viele.filter((b) => b.richtung === 'EINNAHME').map((b) => b.betragCent),
|
||
)
|
||
const ausgabenErwartet = summe(
|
||
viele.filter((b) => b.richtung === 'AUSGABE').map((b) => b.betragCent),
|
||
)
|
||
expect(daten.summeEinnahmenCent).toBe(einnahmenErwartet)
|
||
expect(daten.summeAusgabenCent).toBe(ausgabenErwartet)
|
||
expect(summe(daten.einnahmen.map((g) => g.summeCent))).toBe(einnahmenErwartet)
|
||
expect(summe(daten.ausgaben.map((g) => g.summeCent))).toBe(ausgabenErwartet)
|
||
})
|
||
})
|