Files
Florian Wilde 3833a9dd8f docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte
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
2026-08-22 02:35:08 +02:00

200 lines
7.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { tag } from '@/lib/fristen/datum'
import {
leerstandCheckliste,
naechsteFaelligkeit,
naechsterStichtagImJahr,
} from '@/lib/immobilie/leerstand-checkliste'
import {
durchschnittlicheJahreskosten,
kostenkontoAufbauen,
} from '@/lib/immobilie/kostenkonto'
import { inventarNotizLesen, inventarNotizSchreiben, inventarSumme } from '@/lib/immobilie/inventar'
import { centAusText } from '@/lib/validierung/substanz'
describe('Pflichten-Checkliste bei Leerstand', () => {
const heute = tag(2026, 3, 15)
it('nennt die Meldung an den Gebäudeversicherer als dringenden Punkt', () => {
const punkte = leerstandCheckliste(tag(2026, 1, 10), heute)
const versicherung = punkte.find((p) => p.schluessel === 'gebaeudeversicherung-leerstand-melden')
expect(versicherung).toBeDefined()
expect(versicherung!.dringend).toBe(true)
expect(versicherung!.beschreibung).toMatch(/leistungsfrei/i)
})
it('legt den Frostschutz auf den Oktober', () => {
const punkte = leerstandCheckliste(tag(2026, 1, 10), heute)
const frostschutz = punkte.find((p) => p.schluessel === 'frostschutz')
expect(frostschutz).toBeDefined()
expect(frostschutz!.faelligAm.getUTCMonth() + 1).toBe(10)
expect(frostschutz!.faelligAm.getUTCFullYear()).toBe(2026)
expect(frostschutz!.wiederholungRrule).toContain('BYMONTH=10')
})
it('schiebt den Frostschutz ins Folgejahr, wenn der Oktober schon vorbei ist', () => {
const punkte = leerstandCheckliste(tag(2026, 1, 10), tag(2026, 11, 2))
const frostschutz = punkte.find((p) => p.schluessel === 'frostschutz')!
expect(frostschutz.faelligAm.getUTCFullYear()).toBe(2027)
expect(frostschutz.faelligAm.getUTCMonth() + 1).toBe(10)
})
it('lässt den Kontrollgang wöchentlich wiederkehren', () => {
const kontrollgang = leerstandCheckliste(tag(2026, 1, 10), heute).find(
(p) => p.schluessel === 'kontrollgang',
)!
expect(kontrollgang.wiederholungRrule).toBe('FREQ=WEEKLY;INTERVAL=1')
})
it('setzt keine Fälligkeit in die Vergangenheit', () => {
const punkte = leerstandCheckliste(tag(2020, 1, 1), heute)
for (const punkt of punkte) {
expect(punkt.faelligAm.getTime()).toBeGreaterThanOrEqual(heute.getTime())
}
})
it('deckt alle Pflichten ab, die bei Leerstand teuer werden können', () => {
const schluessel = leerstandCheckliste(null, heute).map((p) => p.schluessel)
expect(schluessel).toEqual(
expect.arrayContaining([
'gebaeudeversicherung-leerstand-melden',
'kontrollgang',
'frostschutz',
'winterdienst-verkehrssicherung',
'zaehlerstaende',
'schluesselverzeichnis',
'fotodokumentation',
'briefkasten-nachsendeauftrag',
'rauchmelder',
'heizungswartung',
]),
)
})
it('findet den nächsten Oktobertermin auch am Stichtag selbst', () => {
expect(naechsterStichtagImJahr(tag(2026, 10, 1), 10, 1)).toEqual(tag(2026, 10, 1))
expect(naechsterStichtagImJahr(tag(2026, 10, 2), 10, 1)).toEqual(tag(2027, 10, 1))
})
it('rechnet den nächsten Kontrollgang eine Woche nach dem Erledigen', () => {
expect(naechsteFaelligkeit('FREQ=WEEKLY;INTERVAL=1', tag(2026, 3, 15))).toEqual(
tag(2026, 3, 22),
)
})
it('rechnet den nächsten Frostschutz auf den Oktober des Folgejahres', () => {
expect(
naechsteFaelligkeit('FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=1', tag(2026, 10, 1)),
).toEqual(tag(2027, 10, 1))
})
})
describe('Kostenkonto der Immobilie', () => {
const buchungen = [
{ datum: tag(2025, 2, 3), betragCent: 12_000n, richtung: 'AUSGABE' as const, kategorie: 'GRUNDSTEUER' as const },
{ datum: tag(2025, 5, 3), betragCent: 12_000n, richtung: 'AUSGABE' as const, kategorie: 'GRUNDSTEUER' as const },
{ datum: tag(2025, 7, 1), betragCent: 48_000n, richtung: 'AUSGABE' as const, kategorie: 'IMMOBILIE_BETRIEBSKOSTEN' as const },
{ datum: tag(2025, 9, 9), betragCent: 150_000n, richtung: 'AUSGABE' as const, kategorie: 'IMMOBILIE_INSTANDHALTUNG' as const },
{ datum: tag(2026, 2, 3), betragCent: 13_000n, richtung: 'AUSGABE' as const, kategorie: 'GRUNDSTEUER' as const },
// Fremde Kategorien und Umbuchungen bleiben draußen.
{ datum: tag(2025, 3, 1), betragCent: 90_000n, richtung: 'AUSGABE' as const, kategorie: 'HEIMKOSTEN' as const },
{ datum: tag(2025, 3, 2), betragCent: 50_000n, richtung: 'UMBUCHUNG' as const, kategorie: 'GRUNDSTEUER' as const },
]
it('summiert je Jahr und Kategorie', () => {
const jahre = kostenkontoAufbauen(buchungen)
expect(jahre.map((j) => j.jahr)).toEqual([2026, 2025])
const zweitausendfuenfundzwanzig = jahre.find((j) => j.jahr === 2025)!
expect(zweitausendfuenfundzwanzig.ausgabenCent).toBe(222_000n)
expect(
zweitausendfuenfundzwanzig.ausgaben.find((z) => z.kategorie === 'GRUNDSTEUER')?.summeCent,
).toBe(24_000n)
expect(
zweitausendfuenfundzwanzig.ausgaben.find((z) => z.kategorie === 'GRUNDSTEUER')?.anzahl,
).toBe(2)
})
it('lässt Umbuchungen und fremde Kategorien außen vor', () => {
const jahre = kostenkontoAufbauen(buchungen)
const zweitausendfuenfundzwanzig = jahre.find((j) => j.jahr === 2025)!
expect(zweitausendfuenfundzwanzig.anzahlBuchungen).toBe(4)
})
it('zählt Mieteinnahmen als Einnahme gegen die Kosten', () => {
const jahre = kostenkontoAufbauen([
...buchungen,
{ datum: tag(2025, 6, 1), betragCent: 60_000n, richtung: 'EINNAHME' as const, kategorie: 'MIETEINNAHME' as const },
])
const zweitausendfuenfundzwanzig = jahre.find((j) => j.jahr === 2025)!
expect(zweitausendfuenfundzwanzig.einnahmenCent).toBe(60_000n)
expect(zweitausendfuenfundzwanzig.saldoCent).toBe(60_000n - 222_000n)
})
it('rechnet den Durchschnitt nur über abgeschlossene Jahre', () => {
const jahre = kostenkontoAufbauen(buchungen)
expect(durchschnittlicheJahreskosten(jahre, 2026)).toBe(222_000n)
})
})
describe('Inventarliste', () => {
it('schreibt und liest Raum und Verbleib verlustfrei', () => {
const text = inventarNotizSchreiben({
raum: 'Wohnzimmer',
verbleib: 'im Haus',
bemerkung: 'Standuhr des Großvaters',
})
expect(inventarNotizLesen(text)).toEqual({
raum: 'Wohnzimmer',
verbleib: 'im Haus',
bemerkung: 'Standuhr des Großvaters',
})
})
it('rettet eine formlose Notiz aus einem anderen Modul in die Bemerkung', () => {
expect(inventarNotizLesen('von Hand notiert')).toEqual({
raum: null,
verbleib: null,
bemerkung: 'von Hand notiert',
})
})
it('summiert die geschätzten Werte', () => {
expect(
inventarSumme([{ wertCent: 25_000n }, { wertCent: null }, { wertCent: 5_000n }]),
).toBe(30_000n)
})
})
describe('Betragseingabe', () => {
it('liest deutsche und englische Schreibweise als Cent', () => {
expect(centAusText('1.234,56')).toBe(123_456n)
expect(centAusText('1234.56')).toBe(123_456n)
expect(centAusText('8,15')).toBe(815n)
expect(centAusText('1.000')).toBe(100_000n)
expect(centAusText('12')).toBe(1_200n)
expect(centAusText('-45,90')).toBe(-4_590n)
})
it('rundet nicht über Fließkomma - 8,15 Euro bleiben 815 Cent', () => {
expect(centAusText('8,15')).toBe(815n)
expect(centAusText('0,07')).toBe(7n)
expect(centAusText('1,1')).toBe(110n)
})
it('weist Unsinn zurück', () => {
expect(centAusText('viel')).toBeNull()
expect(centAusText('')).toBeNull()
expect(centAusText('1,234')).toBeNull()
})
})