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
200 lines
7.4 KiB
TypeScript
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()
|
|
})
|
|
})
|