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
@@ -0,0 +1,109 @@
import { notFound } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Buchungsformular } from '@/components/finanzen/buchungs-formular'
import { Loeschknopf } from '@/components/finanzen/aktionen'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { formatDatum, formatDatumZeit } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { tagAlsText } from '@/lib/finanzen/datum'
import { betragFormatieren } from '@/lib/finanzen/betrag'
import { buchungLoeschen } from '@/server/actions/finanzen'
export const metadata = { title: 'Buchung' }
export default async function BuchungSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const { betreute, darfSchreiben } = await finanzKontext()
const schreiben = darfSchreiben('FINANZEN')
const buchung = await db.buchung.findFirst({
where: { id, betreuteId: betreute.id, ...nichtGeloescht },
})
if (!buchung) notFound()
const offen = oeffnen('Buchung', buchung)
const [konten, belege] = await Promise.all([
db.konto.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, istBetreuerkonto: true },
orderBy: [{ istBetreuerkonto: 'asc' }, { bezeichnung: 'asc' }],
}),
db.dokument.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, titel: true },
orderBy: { erstelltAm: 'desc' },
take: 200,
}),
])
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf
titel={buchung.gegenstelle ?? 'Buchung'}
beschreibung={`Gebucht am ${formatDatum(buchung.datum)}, erfasst am ${formatDatumZeit(buchung.erstelltAm)}.`}
pfad={[
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
{ titel: 'Kassenbuch', href: '/finanzen/buchungen' },
{ titel: formatDatum(buchung.datum) },
]}
/>
{buchung.importReferenz && (
<p className="rounded-md border bg-muted/50 p-3 text-sm text-muted-foreground">
Diese Buchung stammt aus einem Dateiimport. Der Herkunftsschlüssel verhindert, dass
derselbe Umsatz beim nächsten Import ein zweites Mal angelegt wird.
</p>
)}
{schreiben ? (
<Buchungsformular
betreuteId={betreute.id}
konten={konten}
belege={belege}
buchung={{
id: buchung.id,
kontoId: buchung.kontoId,
datum: tagAlsText(buchung.datum),
wertstellung: tagAlsText(buchung.wertstellung) || null,
betrag: betragFormatieren(buchung.betragCent),
richtung: buchung.richtung,
kategorie: buchung.kategorie,
gegenstelle: buchung.gegenstelle,
verwendungszweck: offen.verwendungszweck,
belegId: buchung.belegId,
belegFehltBegruendung: buchung.belegFehltBegruendung,
betrifftBetreute: buchung.betrifftBetreute,
vermoegenswirksam: buchung.vermoegenswirksam,
}}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Nur lesender Zugriff</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Du darfst dieses Modul lesen, aber nicht ändern.
</CardContent>
</Card>
)}
{schreiben && (
<div className="border-t pt-6">
<Loeschknopf
aktion={buchungLoeschen}
felder={{ betreuteId: betreute.id, buchungId: buchung.id }}
beschriftung="Buchung in den Papierkorb"
variante="outline"
frage="Buchung in den Papierkorb legen? Sie bleibt 30 Tage wiederherstellbar. Achtung: Danach geht die Rechenprobe der Rechnungslegung um diesen Betrag nicht mehr auf, solange der Saldostand unverändert bleibt."
/>
</div>
)}
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" />
</div>
)
}