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
This commit is contained in:
Florian Wilde
2026-08-22 02:35:08 +02:00
parent 6b4df4c9cb
commit 3833a9dd8f
141 changed files with 30302 additions and 66 deletions
@@ -0,0 +1,343 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Absendeknopf, Betragseingabe } from '@/components/finanzen/eingaben'
import { Vermischungswarnung } from '@/components/finanzen/bausteine'
import {
ALLE_KATEGORIEN,
BUCHUNG_RICHTUNGEN,
KATEGORIE_BESCHRIFTUNG,
RICHTUNG_BESCHRIFTUNG,
kategorienFuerRichtung,
standardKategorie,
} from '@/lib/finanzen/kategorien'
import { buchungSpeichern, type Formularzustand } from '@/server/actions/finanzen'
import type { BuchungKategorie, BuchungRichtung } from '@prisma/client'
export type KontoAuswahl = {
id: string
bezeichnung: string
istBetreuerkonto: boolean
}
export type BelegAuswahl = {
id: string
titel: string
}
export type BuchungWerte = {
id: string
kontoId: string | null
datum: string
wertstellung: string | null
betrag: string
richtung: BuchungRichtung
kategorie: BuchungKategorie
gegenstelle: string | null
verwendungszweck: string | null
belegId: string | null
belegFehltBegruendung: string | null
betrifftBetreute: boolean
vermoegenswirksam: boolean
}
/**
* Buchung erfassen oder ändern.
*
* Drei Dinge macht dieses Formular anders als eine Haushaltsbuch-App:
*
* 1. Es warnt sichtbar, wenn eine Buchung auf einem Konto des Betreuers
* läuft und trotzdem als Vermögen der Betreuten gilt.
* 2. Es lässt eine Buchung ohne Beleg zu – verlangt dann aber eine
* Begründung, weil genau danach gefragt wird.
* 3. Es trennt laufende Vorgänge von vermögenswirksamen. Ein
* Verkaufserlös ist keine Einnahme im Sinne der Rechnungslegung.
*/
export function Buchungsformular({
betreuteId,
konten,
belege,
buchung,
standardDatum,
schnellerfassung = false,
}: {
betreuteId: string
konten: KontoAuswahl[]
belege: BelegAuswahl[]
buchung?: BuchungWerte
/** Vorbelegung des Datumsfeldes bei einer neuen Buchung. */
standardDatum?: string
/** Nach dem Speichern bleibt das Formular für die nächste Buchung offen. */
schnellerfassung?: boolean
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(buchungSpeichern, {})
const [kontoId, setKontoId] = React.useState(buchung?.kontoId ?? konten[0]?.id ?? '')
const [richtung, setRichtung] = React.useState<BuchungRichtung>(buchung?.richtung ?? 'AUSGABE')
const [kategorie, setKategorie] = React.useState<BuchungKategorie>(
buchung?.kategorie ?? 'SONSTIGE_AUSGABE',
)
const [betrifftBetreute, setBetrifftBetreute] = React.useState(buchung?.betrifftBetreute ?? true)
const [belegId, setBelegId] = React.useState(buchung?.belegId ?? '')
const konto = konten.find((k) => k.id === kontoId)
const vermischt = Boolean(konto?.istBetreuerkonto) && betrifftBetreute
const vorschlaege = kategorienFuerRichtung(richtung)
function richtungWechseln(neu: BuchungRichtung) {
setRichtung(neu)
// Die Kategorie folgt der Richtung, solange sie nicht dazu passt -
// sonst steht "Heimkosten" unter den Einnahmen.
if (!kategorienFuerRichtung(neu).includes(kategorie)) {
setKategorie(standardKategorie(neu))
}
}
return (
<form action={absenden} className="space-y-6">
<input type="hidden" name="betreuteId" value={betreuteId} />
{buchung && <input type="hidden" name="buchungId" value={buchung.id} />}
{schnellerfassung && !buchung && <input type="hidden" name="weiterErfassen" value="on" />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertTitle>Gespeichert</AlertTitle>
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="datum"
beschriftung="Buchungsdatum"
pflicht
erklaerung="Der Tag, an dem das Geld geflossen ist."
fehler={zustand.felder?.datum}
>
<Input
type="date"
name="datum"
defaultValue={buchung?.datum ?? standardDatum}
required
/>
</Feld>
<Feld
id="wertstellung"
beschriftung="Wertstellung"
erklaerung="Optional – nur, wenn sie vom Buchungsdatum abweicht."
fehler={zustand.felder?.wertstellung}
>
<Input type="date" name="wertstellung" defaultValue={buchung?.wertstellung ?? ''} />
</Feld>
<Feld
id="betrag"
beschriftung="Betrag"
pflicht
erklaerung="Immer positiv eintragen. Ob es Geld kostet oder bringt, sagt die Richtung."
fehler={zustand.felder?.betrag}
>
<Betragseingabe id="betrag" name="betrag" standardwert={buchung?.betrag} pflicht />
</Feld>
<Feld
id="richtung"
beschriftung="Richtung"
pflicht
erklaerung="Umbuchungen zwischen eigenen Konten zählen weder als Einnahme noch als Ausgabe."
fehler={zustand.felder?.richtung}
>
<Select
name="richtung"
value={richtung}
onChange={(ereignis) => richtungWechseln(ereignis.target.value as BuchungRichtung)}
>
{BUCHUNG_RICHTUNGEN.map((wert) => (
<option key={wert} value={wert}>
{RICHTUNG_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="kategorie"
beschriftung="Kategorie"
pflicht
erklaerung="Nach diesen Kategorien ordnet die Rechnungslegung Einnahmen und Ausgaben."
fehler={zustand.felder?.kategorie}
>
<Select
name="kategorie"
value={kategorie}
onChange={(ereignis) => setKategorie(ereignis.target.value as BuchungKategorie)}
>
<optgroup label={`Passend zu „${RICHTUNG_BESCHRIFTUNG[richtung]}“`}>
{vorschlaege.map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</optgroup>
<optgroup label="Alle übrigen">
{ALLE_KATEGORIEN.filter((wert) => !vorschlaege.includes(wert)).map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</optgroup>
</Select>
</Feld>
<Feld
id="kontoId"
beschriftung="Konto"
erklaerung="Ohne Konto lässt sich die Buchung später keinem Kontostand zuordnen."
fehler={zustand.felder?.kontoId}
>
<Select
name="kontoId"
value={kontoId}
onChange={(ereignis) => setKontoId(ereignis.target.value)}
>
<option value="">– kein Konto –</option>
{konten.map((k) => (
<option key={k.id} value={k.id}>
{k.bezeichnung}
{k.istBetreuerkonto ? ' (Konto des Betreuers)' : ''}
</option>
))}
</Select>
</Feld>
</div>
<Feld
id="gegenstelle"
beschriftung="Gegenstelle"
erklaerung="Wer hat gezahlt oder wer wurde bezahlt? Steht später im Belegverzeichnis."
fehler={zustand.felder?.gegenstelle}
>
<Input name="gegenstelle" defaultValue={buchung?.gegenstelle ?? ''} maxLength={160} />
</Feld>
<Feld
id="verwendungszweck"
beschriftung="Verwendungszweck"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.verwendungszweck}
>
<Textarea
name="verwendungszweck"
defaultValue={buchung?.verwendungszweck ?? ''}
rows={2}
maxLength={500}
/>
</Feld>
{/* ----------------------------------------------------------- */}
<fieldset className="space-y-4 rounded-md border p-4">
<legend className="px-1 text-sm font-medium">Beleg</legend>
<Feld
id="belegId"
beschriftung="Beleg aus den Dokumenten"
erklaerung="Rechnung, Quittung oder Kontoauszug, der diese Buchung belegt."
fehler={zustand.felder?.belegId}
>
<Select
name="belegId"
value={belegId}
onChange={(ereignis) => setBelegId(ereignis.target.value)}
>
<option value="">– kein Beleg hinterlegt –</option>
{belege.map((beleg) => (
<option key={beleg.id} value={beleg.id}>
{beleg.titel}
</option>
))}
</Select>
</Feld>
{belegId === '' && (
<Feld
id="belegFehltBegruendung"
beschriftung="Warum fehlt der Beleg?"
erklaerung="Ohne Beleg und ohne Begründung wird die Buchung in der Liste rot markiert und in der Belegquote gezählt. Verboten ist sie nicht – aber beim Gericht wird danach gefragt."
fehler={zustand.felder?.belegFehltBegruendung}
>
<Input
name="belegFehltBegruendung"
defaultValue={buchung?.belegFehltBegruendung ?? ''}
maxLength={300}
placeholder="z.B. Barkauf im Kiosk, keine Quittung erhalten"
/>
</Feld>
)}
</fieldset>
{/* ----------------------------------------------------------- */}
<fieldset className="space-y-4 rounded-md border p-4">
<legend className="px-1 text-sm font-medium">Zuordnung</legend>
<label className="flex items-start gap-3">
<Checkbox
name="betrifftBetreute"
checked={betrifftBetreute}
onChange={(ereignis) => setBetrifftBetreute(ereignis.target.checked)}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block font-medium">
Betrifft das Vermögen der betreuten Person
</span>
<span className="block text-sm text-muted-foreground">
Abwählen, wenn du die Ausgabe aus eigener Tasche bezahlt hast. Solche Auslagen
stehen in der Rechnungslegung getrennt und werden nach §§ 1877, 1878 BGB erstattet.
</span>
</span>
</label>
{vermischt && <Vermischungswarnung />}
<label className="flex items-start gap-3">
<Checkbox
name="vermoegenswirksam"
defaultChecked={buchung?.vermoegenswirksam}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block font-medium">Vermögenswirksam</span>
<span className="block text-sm text-muted-foreground">
Für Vorgänge, die Vermögen verschieben statt laufend einzunehmen oder auszugeben –
Verkaufserlös, ausgezahlter Rückkaufswert, Erbschaft. Sie zählen in der
Rechenprobe mit, gelten aber nicht als laufende Einnahme.
</span>
</span>
</label>
</fieldset>
<div className="flex flex-wrap gap-3">
<Absendeknopf>
{buchung ? 'Änderungen speichern' : schnellerfassung ? 'Speichern und nächste' : 'Buchung erfassen'}
</Absendeknopf>
</div>
</form>
)
}