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
151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
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',
|
|
},
|
|
})
|
|
}
|