feat: Konten, Kassenbuch, Vermoegen und die beiden Pflicht-PDFs

Etappe 4. Aus diesen Daten entstehen das Vermoegensverzeichnis nach
§ 1835 BGB und die Rechnungslegung nach §§ 1859, 1865 BGB - beides geht
ans Betreuungsgericht, beides ohne dass eine Zahl abgetippt wird.

Betreuungsjahr n laeuft von bestellungAm + (n-1) Jahre bis
bestellungAm + n Jahre - 1 Tag. Bei Bestellung am 29. Februar endet Jahr
1 am 27.02. und Jahr 2 beginnt am 28.02.: lueckenlos und ohne
Ueberschneidung, per Test abgesichert.

Rechenprobe der Rechnungslegung: Der Anfangsbestand ist der juengste
Saldostand bis zum VORTAG des Zeitraumbeginns - ein Saldostand gilt als
Bestand am Tagesende, sonst zaehlte der erste Tag doppelt. Geht die
Probe nicht auf, wird die Differenz im PDF ausgewiesen und jeder
fehlende Saldostand namentlich benannt. Nie geglaettet: eine still
korrigierte Rechnungslegung ist gegenueber dem Gericht wertlos.

Posten ohne Wertstand zum Stichtag erscheinen als Luecke, nicht als
0,00 EUR - und Werte aus der Zukunft werden nie herangezogen. Konten
kommen ueber ihren Saldostand ins Verzeichnis; Vermoegensposten der Art
KONTO werden dort uebersprungen, sonst stuende das Geld doppelt drin.

Der CSV- und CAMT-Import raet KEINE Kategorien, sondern bucht alles auf
"Sonstige". Eine falsch geratene Kategorie faellt in der Jahresuebersicht
niemandem auf und verfaelscht den Bericht. Fremdwaehrung wird abgelehnt
statt umgerechnet. Der XML-Leser weist DOCTYPE ab (XXE).

68 Tests, darunter echte IBAN-Pruefziffern und die Rundung: es darf
nirgends ein Cent verschwinden.

Etappe: 4
Phase: Geld
This commit is contained in:
Florian Wilde
2026-08-22 06:21:45 +02:00
parent 56d4cca8c9
commit e7006c894e
10 changed files with 1358 additions and 71 deletions
+285
View File
@@ -0,0 +1,285 @@
import Link from 'next/link'
import { AlertCircle, FileDown } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Hinweisliste, Summenzeile } from '@/components/finanzen/bausteine'
import { centZuZahl } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { heute, tagAlsText } from '@/lib/finanzen/datum'
import {
aufwandspauschaleBis,
betreuungsjahre,
jahrAbgeschlossen,
} from '@/lib/finanzen/betreuungsjahr'
import { standardVariante, VARIANTE_BESCHRIFTUNG } from '@/lib/finanzen/rechnungslegung'
import { rechnungslegungDaten } from '@/lib/berichte/gemeinsam'
export const metadata = { title: 'Berichte' }
/**
* Erzeugung der beiden Berichte ans Betreuungsgericht.
*
* Bevor eine PDF entsteht, steht hier die Rechenprobe des zuletzt
* abgeschlossenen Betreuungsjahres. Der Gedanke: Eine Differenz will man
* sehen, bevor man das Dokument unterschreibt – nicht danach.
*/
export default async function BerichteSeite() {
const { betreute } = await finanzKontext()
const stichtagHeute = heute()
const jahre = betreute.bestellungAm ? betreuungsjahre(betreute.bestellungAm, stichtagHeute) : []
const abgeschlossene = jahre.filter((jahr) => jahrAbgeschlossen(jahr, stichtagHeute))
const letztesAbgeschlossen = abgeschlossene[abgeschlossene.length - 1] ?? null
const vorauswahl = letztesAbgeschlossen ?? jahre[jahre.length - 1] ?? null
const variante = standardVariante(betreute.befreiterBetreuer)
// Vorschau der Rechenprobe - nur, wenn es ein Jahr dafuer gibt.
const vorschau = vorauswahl
? await rechnungslegungDaten({
betreute,
jahrNummer: vorauswahl.nummer,
variante,
erstelltVon: '(Vorschau)',
})
: null
return (
<div className="space-y-8">
<SeitenKopf
titel="Berichte"
beschreibung="Vermögensverzeichnis und Rechnungslegung – beide entstehen unmittelbar aus den erfassten Konten, Buchungen und Wertständen. Es wird nichts abgetippt."
pfad={[{ titel: 'Vermögen & Kasse', href: '/finanzen' }, { titel: 'Berichte' }]}
/>
{!betreute.bestellungAm && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das Bestellungsdatum fehlt</AlertTitle>
<AlertDescription>
Das Betreuungsjahr läuft ab dem Tag der Bestellung, nicht ab dem 1. Januar. Ohne
dieses Datum lässt sich die Rechnungslegung nicht abgrenzen. Trag es in den Stammdaten
nach – das Vermögensverzeichnis kannst du auch so erzeugen.
</AlertDescription>
</Alert>
)}
{/* ------------------------------------------------------------- */}
<Card>
<CardHeader>
<CardTitle>Vermögensverzeichnis (§ 1835 BGB)</CardTitle>
<CardDescription>
Binnen drei Monaten nach der Bestellung beim Betreuungsgericht einzureichen. Zeigt
Aktiva nach Gruppen, Passiva, Reinvermögen sowie die laufenden Einnahmen und Ausgaben.
Jeder Wert trägt seine Quelle und den Tag seiner Bewertung.
</CardDescription>
</CardHeader>
<CardContent>
<form method="get" action="/finanzen/berichte/pdf" className="space-y-4">
<input type="hidden" name="art" value="vermoegensverzeichnis" />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="stichtag"
beschriftung="Stichtag"
pflicht
erklaerung="Es wird je Posten der jüngste Wertstand verwendet, der nicht nach diesem Tag liegt."
>
<Input
type="date"
name="stichtag"
defaultValue={tagAlsText(betreute.bestellungAm ?? stichtagHeute)}
required
/>
</Feld>
<Feld
id="monate"
beschriftung="Rückblick für laufende Einnahmen und Ausgaben"
erklaerung="Aus wie vielen Monaten Kassenbuch soll der Monatsdurchschnitt gebildet werden?"
>
<Select name="monate" defaultValue="12">
<option value="3">3 Monate</option>
<option value="6">6 Monate</option>
<option value="12">12 Monate</option>
<option value="24">24 Monate</option>
</Select>
</Feld>
</div>
<Button type="submit">
<FileDown aria-hidden="true" />
Vermögensverzeichnis erzeugen
</Button>
</form>
</CardContent>
</Card>
{/* ------------------------------------------------------------- */}
<Card>
<CardHeader>
<CardTitle>Vermögensübersicht und Rechnungslegung (§§ 1859, 1865 BGB)</CardTitle>
<CardDescription>
{betreute.befreiterBetreuer
? 'Als befreiter Betreuer schuldest du nach § 1859 BGB nur die knappe Vermögensübersicht. Die vollständige Rechnungslegung kannst du trotzdem erzeugen – etwa, wenn das Gericht sie ausdrücklich verlangt.'
: 'Es ist die vollständige Rechnungslegung nach § 1865 BGB geschuldet: jede Einnahme, jede Ausgabe, jeder Beleg mit laufender Nummer.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{jahre.length === 0 ? (
<p className="text-sm text-muted-foreground">
Sobald das Bestellungsdatum in den Stammdaten steht, kannst du hier je
Betreuungsjahr abrechnen.
</p>
) : (
<form method="get" action="/finanzen/berichte/pdf" className="space-y-4">
<input type="hidden" name="art" value="rechnungslegung" />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="jahr"
beschriftung="Betreuungsjahr"
pflicht
erklaerung="Das Betreuungsjahr läuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr."
>
<Select name="jahr" defaultValue={String(vorauswahl?.nummer ?? 1)}>
{jahre.map((jahr) => (
<option key={jahr.nummer} value={jahr.nummer}>
{jahr.bezeichnung}
{jahrAbgeschlossen(jahr, stichtagHeute) ? '' : ' – läuft noch'}
</option>
))}
</Select>
</Feld>
<Feld
id="variante"
beschriftung="Variante"
pflicht
erklaerung="Vorbelegt nach dem Eintrag „befreiter Betreuer“ in den Stammdaten – hier jederzeit umschaltbar."
>
<Select name="variante" defaultValue={variante}>
<option value="UEBERSICHT_1859">
{VARIANTE_BESCHRIFTUNG.UEBERSICHT_1859}
</option>
<option value="VOLL_1865">{VARIANTE_BESCHRIFTUNG.VOLL_1865}</option>
</Select>
</Feld>
</div>
<Button type="submit">
<FileDown aria-hidden="true" />
Bericht erzeugen
</Button>
</form>
)}
{vorauswahl && !jahrAbgeschlossen(vorauswahl, stichtagHeute) && (
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertTitle>Noch keine Schlussrechnung möglich</AlertTitle>
<AlertDescription>
Das {vorauswahl.nummer}. Betreuungsjahr läuft noch bis zum{' '}
{formatDatum(vorauswahl.bis)}. Der Bericht wird als Zwischenaufstellung
gekennzeichnet.
</AlertDescription>
</Alert>
)}
{letztesAbgeschlossen && (
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertTitle>Aufwandspauschale nicht vergessen</AlertTitle>
<AlertDescription>
Für das {letztesAbgeschlossen.nummer}. Betreuungsjahr (Ende{' '}
{formatDatum(letztesAbgeschlossen.bis)}) kann die Aufwandspauschale nach § 1878
BGB bis zum {formatDatum(aufwandspauschaleBis(letztesAbgeschlossen))} beantragt
werden. Das ist eine Ausschlussfrist – danach ist der Anspruch weg.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* ------------------------------------------------------------- */}
{vorschau && (
<Card>
<CardHeader>
<CardTitle>Rechenprobe – {vorschau.jahr.bezeichnung}</CardTitle>
<CardDescription>
Anfangsbestand zuzüglich Einnahmen abzüglich Ausgaben muss den Endbestand ergeben.
Stimmt es nicht, steht die Differenz auch im PDF – sie wird nicht stillschweigend
glattgezogen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Summenzeile
beschriftung={`Anfangsbestand am ${formatDatum(vorschau.rechnung.von)}`}
cent={centZuZahl(vorschau.rechnung.probe.anfangsbestandCent) ?? 0}
/>
<Summenzeile
beschriftung="zuzüglich Einnahmen"
cent={centZuZahl(vorschau.rechnung.probe.einnahmenCent) ?? 0}
/>
<Summenzeile
beschriftung="abzüglich Ausgaben"
cent={-(centZuZahl(vorschau.rechnung.probe.ausgabenCent) ?? 0)}
/>
<Summenzeile
beschriftung="rechnerischer Endbestand"
cent={centZuZahl(vorschau.rechnung.probe.rechnerischerEndbestandCent) ?? 0}
betont
/>
<Summenzeile
beschriftung={`Endbestand laut Saldoständen am ${formatDatum(vorschau.rechnung.bis)}`}
cent={centZuZahl(vorschau.rechnung.probe.gebuchterEndbestandCent) ?? 0}
/>
</div>
{vorschau.rechnung.probe.stimmt ? (
<Alert variant="erfolg">
<AlertCircle aria-hidden="true" />
<AlertTitle>Die Rechnung geht auf</AlertTitle>
<AlertDescription>
Bestände und Buchungen passen auf den Cent zusammen.
</AlertDescription>
</Alert>
) : (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>
Differenz:{' '}
{formatCent(centZuZahl(vorschau.rechnung.probe.differenzCent))}
</AlertTitle>
<AlertDescription>
Entweder fehlen Buchungen im Kassenbuch, oder ein Saldostand ist nicht erfasst.
Am schnellsten findest du die Lücke über{' '}
<Link href="/finanzen/konten" className="underline">
die Kontostände
</Link>{' '}
und die{' '}
<Link
href={`/finanzen/buchungen?von=${tagAlsText(vorschau.rechnung.von)}&bis=${tagAlsText(vorschau.rechnung.bis)}`}
className="underline"
>
Buchungen dieses Zeitraums
</Link>
.
</AlertDescription>
</Alert>
)}
<Hinweisliste hinweise={vorschau.rechnung.hinweise} />
</CardContent>
</Card>
)}
<Rechtshinweis rechtsgrundlage="§ 1835 BGB, § 1859 BGB, § 1865 BGB, § 1878 BGB" />
</div>
)
}
@@ -0,0 +1,150 @@
import { NextResponse, type NextRequest } from 'next/server'
import { finanzKontext } from '@/lib/finanzen/seite'
import { tagAusText } from '@/lib/finanzen/datum'
import { betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import { standardVariante, type Variante } from '@/lib/finanzen/rechnungslegung'
import {
berichtVermerken,
rechnungslegungDaten,
vermoegensverzeichnisDaten,
} from '@/lib/berichte/gemeinsam'
import {
vermoegensverzeichnisDateiname,
vermoegensverzeichnisPdf,
} from '@/lib/berichte/vermoegensverzeichnis'
import { rechnungslegungDateiname, rechnungslegungPdf } from '@/lib/berichte/rechnungslegung'
/**
* Auslieferung der beiden Finanzberichte als PDF.
*
* Die Datei wird bei jedem Abruf frisch aus den Daten erzeugt - es gibt
* keinen zwischengespeicherten Stand, der von der Datenbank abweichen
* koennte. Dass ein Bericht erzeugt wurde, wird in der Tabelle Bericht
* und im Audit-Log festgehalten: Gegenueber dem Gericht muss ich sagen
* koennen, was ich wann in welcher Fassung abgegeben habe.
*
* Ausgeliefert wird immer als Anhang und mit nosniff - ein Dokument aus
* dieser Anwendung wird nie im Browser als HTML ausgefuehrt.
*/
export async function GET(anfrage: NextRequest): Promise<Response> {
const { betreute, benutzer } = await finanzKontext()
const parameter = anfrage.nextUrl.searchParams
const art = parameter.get('art')
try {
if (art === 'vermoegensverzeichnis') {
const stichtag = tagAusText(parameter.get('stichtag'))
if (!stichtag) {
return fehlerantwort('Es fehlt ein gültiger Stichtag im Format JJJJ-MM-TT.')
}
const monateRoh = Number.parseInt(parameter.get('monate') ?? '12', 10)
const monateRueckblick =
Number.isInteger(monateRoh) && monateRoh >= 1 && monateRoh <= 60 ? monateRoh : 12
const daten = await vermoegensverzeichnisDaten({
betreute,
stichtag,
erstelltVon: benutzer.name,
monateRueckblick,
})
const pdf = await vermoegensverzeichnisPdf(daten)
const dateiname = vermoegensverzeichnisDateiname(daten)
await berichtVermerken({
betreuteId: betreute.id,
benutzer,
art: 'VERMOEGENSVERZEICHNIS',
titel: `Vermögensverzeichnis zum ${dateiname.slice(0, 10)}`,
stichtag,
dateiname,
})
return pdfAntwort(pdf, dateiname)
}
if (art === 'rechnungslegung') {
if (!betreute.bestellungAm) {
return fehlerantwort(
'Ohne Bestellungsdatum lässt sich kein Betreuungsjahr bestimmen. Bitte zuerst in den ' +
'Stammdaten das Datum der Bestellung eintragen.',
)
}
const jahrNummer = Number.parseInt(parameter.get('jahr') ?? '', 10)
if (!Number.isInteger(jahrNummer) || jahrNummer < 1) {
return fehlerantwort('Es fehlt die Nummer des Betreuungsjahres.')
}
const gewaehlt = parameter.get('variante')
const variante: Variante =
gewaehlt === 'UEBERSICHT_1859' || gewaehlt === 'VOLL_1865'
? gewaehlt
: standardVariante(betreute.befreiterBetreuer)
const daten = await rechnungslegungDaten({
betreute,
jahrNummer,
variante,
erstelltVon: benutzer.name,
})
const pdf = await rechnungslegungPdf(daten)
const dateiname = rechnungslegungDateiname(daten)
const jahr = betreuungsjahr(betreute.bestellungAm, jahrNummer)
await berichtVermerken({
betreuteId: betreute.id,
benutzer,
art: variante === 'UEBERSICHT_1859' ? 'VERMOEGENSUEBERSICHT' : 'RECHNUNGSLEGUNG',
titel: daten.kopf.titel,
zeitraumVon: jahr.von,
zeitraumBis: jahr.bis,
dateiname,
})
return pdfAntwort(pdf, dateiname)
}
return fehlerantwort(
'Unbekannte Berichtsart. Erwartet wird „vermoegensverzeichnis“ oder „rechnungslegung“.',
)
} catch (ausnahme) {
console.error('[finanzen] Bericht konnte nicht erzeugt werden:', ausnahme)
return fehlerantwort(
ausnahme instanceof Error
? ausnahme.message
: 'Der Bericht konnte nicht erzeugt werden.',
500,
)
}
}
function pdfAntwort(pdf: Buffer, dateiname: string): Response {
// Der Dateiname wird zweimal angegeben: einmal auf ASCII reduziert fuer
// aeltere Browser, einmal UTF-8-kodiert nach RFC 5987 fuer die uebrigen.
const ascii = dateiname.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
return new NextResponse(new Uint8Array(pdf), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(pdf.byteLength),
'Content-Disposition': `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(dateiname)}`,
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store, max-age=0',
},
})
}
function fehlerantwort(meldung: string, status = 400): Response {
return new NextResponse(meldung, {
status,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store, max-age=0',
},
})
}