Compare commits

...
14 Commits
Author SHA1 Message Date
florian.wilde 96a9bc4d63 Merge #16: Schreiben als E-Mail uebernehmen
Behebt Issue #5: Umschalter Brief/E-Mail im Schreiben-Editor, mailto
fuer kurze Texte, .eml immer, Ablage als gekennzeichnete E-Mail.

Closes #5
2026-08-23 09:18:59 +00:00
Florian Wilde b59d11c9b4 Schreiben als E-Mail uebernehmen
Im Schreiben-Editor gibt es jetzt den Umschalter Brief/E-Mail. Die
E-Mail-Fassung wird aus dem - womoeglich bearbeiteten - Brieftext
abgeleitet: ohne Absenderblock, Anschrift und Datumszeile, aber mit dem
Bestellungsabsatz. Betreff und Text einzeln kopierbar.

Die offene Entscheidung aus dem Issue (mailto oder .eml) ist nach
Rueckfrage mit BEIDES beantwortet:

- mailto fuer kurze Texte. Der Knopf verschwindet, sobald der kodierte
  Link 1800 Zeichen uebersteigt - mailto uebergibt den Text in der
  Adresszeile, deren Laenge nirgends zugesichert ist, und ein halbierter
  Brief faellt erst beim Empfaenger auf. Die Grenze wird NACH der
  URL-Kodierung gerechnet; 900 Umlaute sind roh harmlos und kodiert
  weit drueber.
- .eml immer: RFC 5322 mit X-Unsent: 1, quoted-printable fuer den Rumpf,
  RFC-2047-Kodierung fuer Umlaute im Betreff. Bewusst ohne From-Kopf -
  ein erfundener Absender wuerde je nach Programm uebernommen.

Beide Knoepfe erscheinen nur mit E-Mail-Adresse (aus dem Kontakt
uebernommen oder von Hand). Ohne Adresse bleibt das Kopieren.

Die Ablage kennt den Unterschied: Eine E-Mail heisst in der Akte
"E-Mail: ...", und abgelegt wird die E-Mail-Fassung samt
Empfaengeradresse im Kopf der PDF - in der Akte soll stehen, was
tatsaechlich hinausging.

Sicherung in emailFassung(): Findet sich keine Anrede (frei umgebauter
Text), wird NICHTS entfernt. Lieber ein Briefkopf zu viel in der Mail
als stillschweigend verlorener Inhalt.

Geprueft: 20 Einheitstests, vier End-to-End-Tests auf beiden
Geraeteprofilen. 590 Unit-Tests, 81 e2e, null Typfehler.

Refs #5
2026-08-23 11:02:00 +02:00
florian.wilde a52523fc97 Merge #15: Schreiben erzeugen, ausgeben und ablegen
Behebt Issue #4: Ein Schreiben geht jetzt den ganzen Weg - bearbeiten,
Word-Datei, Ablage als Ausgangsdokument, Wiedervorlage. Auch an Stellen,
zu denen es noch keinen Datensatz gibt.

Closes #4
2026-08-23 08:18:38 +00:00
Florian Wilde c21d03abf6 Schreiben erzeugen, ausgeben und ablegen
Die Anschreiben gab es bisher nur als Text zum Kopieren. Danach war
Handarbeit: in Word nachbauen, ausdrucken, abschicken - und noch einmal
von Hand als Dokument in die Akte legen. Der letzte Schritt wurde am
haeufigsten vergessen, und dann fehlt der Nachweis im Bericht.

Neu unter /schreiben/neu: bearbeiten, als Word-Datei herunterladen, in
die Akte legen, Wiedervorlage setzen - in einem Zug. Eine Seite fuer alle
Arten und alle drei Empfaengerwege, statt die Bearbeitung dreimal an
Vertrag, Vorgang und Freitext nachzubauen.

Der dritte Weg ist der eigentliche Zugewinn: Schreiben an eine Stelle,
zu der es noch keinen Datensatz gibt. Am Anfang einer Betreuung ist das
der Normalfall - man fragt ja an, um zu erfahren, ob dort ueberhaupt
etwas besteht. Aus der freien Anschrift laesst sich mit einem Klick ein
Kontakt anlegen.

Zwei Entscheidungen, die erklaert gehoeren:

Die Akte bekommt ein PDF, heruntergeladen wird .docx. Der
Objektspeicher nimmt nur PDF und Bilder an, geprueft an den Magic Bytes.
Eine .docx ist ein ZIP-Archiv; sie durchzulassen hiesse jedes ZIP
durchzulassen, und diese Pruefung ist eine bewusste Zusage der
Anwendung. Sachlich ist PDF fuer den Nachweis ohnehin richtiger: Es
aendert sich nicht mehr. Die Oberflaeche sagt dazu, dass eine spaeter in
Word geaenderte Fassung von der Akte abweicht.

Der Berichtssatz in berichte/docx.ts bleibt unangetastet. Er setzt einen
Bericht mit Kopfzeilen und Unterschriftenblock; ein Brief braucht
Absender, Anschriftenfeld, Datumszeile und Grussformel. Beides in eine
Funktion zu zwingen haette beide Ausgaben schlechter gemacht. Der
Umsetzungshinweis im Issue ("muss nur angebunden werden") war insoweit
falsch - von mir selbst geschrieben.

Die vorhandenen Erzeuger (Rueckkaufswert, Kuendigung, Sachstandsanfrage)
bleiben unveraendert; ihr Wortlaut ist getestet und erprobt.

Geprueft: zehn Einheitstests, darunter der Gleichschritt der
Wiedervorlagefristen ueber zwei Module hinweg. Fuenf End-to-End-Tests
auf beiden Geraeteprofilen: Entwurf, Vertragsbezug, Word-Download,
Ablage samt Wiedervorlage, Kontakt aus freier Anschrift.

Refs #4
2026-08-23 10:14:59 +02:00
florian.wilde de21f1c320 Merge #14: Dauerzahlungen gelten als belegt
Behebt Issue #11: Bei einem Dauerschuldverhaeltnis ist der Vertrag der
Beleg - fuer zwoelf gleiche Lastschriften gibt es keine zwoelf
Quittungen, sondern eine Police.

Closes #11
2026-08-23 07:06:26 +00:00
Florian Wilde fa994350d6 Dauerzahlungen zu einem Vertrag gelten als belegt
Bei einer monatlichen Praemie gibt es keinen Beleg - es gibt einen
Vertrag, und der wird einmal abgeschlossen, nicht monatlich quittiert.
Bisher blieb nur ein Freitextfeld pro Buchung: zwoelfmal im Jahr
derselbe Satz, mal Anzahl der Vertraege.

Buchung.vertragId mit onDelete: SetNull. Der Belegzustand kennt jetzt
drei Faelle statt zwei, getrennt ausgewiesen - ein Vertrag belegt, eine
Begruendung erklaert nur; zusammengeworfen verliert die Belegquote ihre
Aussage gegenueber dem Rechtspfleger.

Dazu: automatische Zuordnung beim Import bei eindeutiger Gegenstelle,
Sammelzuordnung ueber die Gruppen aus Issue #3, und die Rechnungslegung
nennt den Vertrag samt Nummer statt "ohne Beleg".

Die Sicherung dagegen: Weicht der Betrag um mehr als ein Fuenftel vom
hinterlegten Beitrag ab, faellt die Anerkennung weg und die Buchung wird
als "Betrag pruefen" markiert. Sonst waere die Zuordnung eine Hintertuer.

Zur Toleranz - ein Entwurf und seine Korrektur: Zuerst verglich ich den
Betrag gegen alle ueblichen Zahlweisen, damit es auch passt, wenn die
Bank anders abbucht als der Vertrag es fuehrt. Der eigene Test hat das
widerlegt: Bei 42,50 monatlich liegen 420 innerhalb von 20 Prozent des
Jahresbetrags von 510 - ausgerechnet der Fall aus der Aufgabenstellung
waere durchgerutscht. Jetzt wird stur gegen den hinterlegten Beitrag
verglichen. Bucht die Bank anders ab, stimmen die Vertragsdaten nicht,
und das soll auffallen.

Behoben: Die Buchungsliste hatte eine eigene, fast gleiche Datenabfrage
neben der aus berichte/gemeinsam.ts. Nur eine von beiden wurde
nachgezogen, in der Liste stand weiter "Beleg fehlt". Beide benutzen
jetzt BUCHUNG_EINBEZUG und zuBuchungEingabe - das kann nicht halb
vergessen werden. Gefunden hat es der Browsertest.

Nebenbei: Die Roadmap trug noch "Aktuelle Fassung 1.1.2" - Nachwirkung
der parallelen Bearbeitung von #3 und #10. Nachgezogen wie zugesagt.

Geprueft: 23 neue Einheitstests, die Haelfte davon darauf gerichtet, dass
die Anerkennung auch wirklich wegfaellt. Fuenf End-to-End-Tests auf
beiden Geraeteprofilen. 560 Unit-Tests, 63 e2e, null Typfehler.

Refs #11
2026-08-23 09:00:46 +02:00
florian.wilde 8779cc6a25 Merge #13: Wiederkehrende Zahlungen aufspueren
Behebt Issue #3: Am Anfang einer Betreuung weiss niemand, welche
Vertraege es gibt. Die Antwort steht im Kontoauszug.

Closes #3
2026-08-23 06:13:43 +00:00
florian.wilde 928d3bc408 Merge #12: Roadmap - Korrespondenz nach 1.2a
Behebt Issue #10: Das Thema Schriftverkehr stand als eine Zeile unter
"Spaeter, ohne Datum" und war damit zu tief gehaengt.

Closes #10
2026-08-23 06:13:35 +00:00
Florian Wilde aa0cc66292 Wiederkehrende Zahlungen aufspueren
Am Anfang einer Betreuung weiss niemand, welche Vertraege es gibt. Die
Antwort steht aber im Kontoauszug: Wer regelmaessig abbucht, hat einen
Vertrag. Neu unter Vermoegen & Kasse wird aus hunderten Buchungszeilen
eine Arbeitsliste.

Aufbau:
- Die Erkennung liegt als reine Funktionen in
  src/lib/finanzen/wiederkehrend.ts, ohne Datenbank. Nur so laesst sich
  jede Einordnung einzeln nachrechnen.
- Der Datenzugriff steht getrennt in wiederkehrend-laden.ts.
- Ein kleines Modell haelt fest, was bewusst KEIN Vertrag ist. Es haengt
  am normalisierten Empfaengernamen, nicht an Buchungen - nur so
  ueberlebt die Entscheidung den naechsten Import.

Zwei Haltungen liegen der Erkennung zugrunde:

Im Zweifel getrennt lassen. Zusammengefasst wird nur das Unstrittige:
Gross- und Kleinschreibung, Satzzeichen, Umlaute, Rechtsform am Ende.
Keine Aehnlichkeitssuche - "Stadtwerke Bamberg" und "Stadtwerke
Bayreuth" trennen drei Zeichen und zwei Rechnungssteller. Zwei Zeilen zu
viel sind harmlos; eine falsche Gruppe verbirgt einen Vertrag.

Nichts behaupten, was nicht belegt ist. Statt "monatlich" steht dort
"6 Zahlungen, etwa monatlich". Schwankt der Betrag, wird die Spanne
genannt. Ohne erkennbaren Abstand wird nicht hochgerechnet, sondern
gezeigt, was geflossen ist.

Dazu eine zweite Liste fuer einzelne groessere Zahlungen: Die
Gruppierung braucht zwei Zahlungen, ein Jahresbeitrag erscheint bei
einem importierten Jahr aber nur einmal - und ausgerechnet
Versicherungen zahlt man oft jaehrlich.

Geprueft: 37 Einheitstests, die Haelfte davon in der Gegenrichtung, plus
vier End-to-End-Tests auf beiden Geraeteprofilen. Der Browsertest hat
zwei Fehler gefunden, die Typpruefung und Einheitstests durchgelassen
haben - darunter ein verlorener Backslash, der die Vorbelegung des
Beitrags stillschweigend verschluckte.

Nebenbei: .claude/ ins .gitignore. Dort legen parallel arbeitende
Agenten ihre Worktrees ab; ein "git add -A" haette einen kompletten
zweiten Checkout eingecheckt.

Refs #3
2026-08-23 02:42:26 +02:00
Florian Wilde d28ff2afc1 Roadmap: Schriftverkehr als eigenen Abschnitt 1.2a fuehren
Das Thema stand als einzelne Zeile unter "Spaeter, ohne Datum" — also
unter "waere schoen, und ehrlich gesagt vielleicht nie". Nach der
Priorisierungsregel der Datei selbst faellt es unter Kriterium 2: Es
kostet jeden Monat Zeit, vor Vermoegensverzeichnis und Jahresbericht
besonders viel.

Neuer Abschnitt 1.2a mit begruendeten Unterpunkten zu #3 (Vertraege
aufspueren), #4 (Schreiben erzeugen, ablegen, nachhalten), #5 (E-Mail),
#6 (Serienabfrage der Rueckkaufswerte), #7 (eigene Vorlagen) und #11
(Dauerzahlungen gelten als belegt). Die Zeile unter "Spaeter, ohne
Datum" entfaellt, damit dasselbe Thema nicht zweimal steht.

"Rueckkaufswert am Vertrag" (1.1, #8) und "Ansprechpartner am Vorgang"
(1.2, #9) bleiben, wo sie stehen, sind aber als zugehoerig kenntlich
gemacht.

Refs #10
2026-08-23 01:52:09 +02:00
florian.wilde 8a594ee4a4 Merge #2: Seitenleiste bleibt stehen, Benutzermenue wieder erreichbar
Behebt Issue #1: "Mein Konto" war am Schreibtisch praktisch nicht zu
finden, weil die Seitenleiste mit der Seite mitwuchs und ihr Fuss am
Seitenende landete.

Closes #1
2026-08-22 23:03:53 +00:00
Florian Wilde 0128bf71e7 Seitenleiste bleibt stehen, Benutzermenue wieder erreichbar
Am Schreibtisch war "Mein Konto" praktisch nicht zu finden - und damit
auch nicht Passwortwechsel, zweiter Faktor und Passkey-Verwaltung.

Die Seitenleiste hatte keine feste Hoehe und wuchs mit der Seite mit. Ihr
Fuss mit dem Benutzermenue landete deshalb am Ende der SEITE statt am
unteren Bildschirmrand: auf /heute bei 1632 Pixel in einem 900 Pixel
hohen Fenster. Nachgemessen, nicht geraten - im Browser auf fuenf Seiten.

Der Fehler sah nach nichts aus. Kein Element fehlte, nichts war kaputt,
die Seite sah richtig aus. Man musste nur ganz nach unten scrollen.

Behoben mit lg:sticky, lg:top-0, lg:h-dvh und lg:self-start. Das
self-start gehoert dazu, weil ein Flex-Kind sonst auf die volle
Zeilenhoehe gestreckt wird und die feste Hoehe wieder verliert. Die
Navigation dazwischen hat bereits overflow-y-auto und scrollt bei Bedarf
in sich selbst.

Am Handy war es nie ein Problem - dort sitzt das Menue in der ohnehin
festen Kopfzeile.

Neu: e2e/benutzermenue.spec.ts fragt nicht, ob das Menue im DOM ist,
sondern ob es im sichtbaren Bereich steht - auf fuenf Seiten, an drei
Scrollpositionen, auf einem flachen Laptopfenster und am Handy. Die
Position in der Mitte ist Absicht: Am Seitenende wird eine mitwachsende
Leiste zufaellig doch sichtbar, der Test waere gruen und der Fehler
trotzdem da. Beide Sichtbarkeitstests sind gegen den kaputten Zustand
gegengeprueft und schlagen dort an.

Refs #1
2026-08-23 01:00:22 +02:00
Florian Wilde 38f4d8d91f fix: Skripte lagen ohne Ausfuehrungsrecht im Repository
Die Anleitung sagt "./scripts/update.sh" - auf Linux kam dort
"Permission denied". Die drei .sh-Dateien lagen mit Modus 644 statt 755
im Index.

Ursache ist der Entwicklungsrechner: Unter Windows steht
core.filemode=false, git nimmt ein fehlendes Ausfuehrungsrecht dort gar
nicht wahr. Gemeldet hat es der Betrieb, nicht der Bau.

Wer von 1.1.0 oder aelter kommt, braucht einmalig "bash
scripts/update.sh" - das alte Skript ist ja noch das nicht ausfuehrbare.
Steht jetzt in README und Installationsanleitung.

Zusaetzlich setzt update.sh nach dem Umschalten selbst ein chmod +x auf
scripts/*.sh. Das faengt den Fall ab, dass jemand Dateien von Hand
kopiert und das Recht dabei verliert - genau so kam man bisher an dieses
Skript, bevor es in der laufenden Fassung existierte.

Neu: tests/skripte.test.ts prueft nicht Verhalten, sondern Verpackung -
Ausfuehrungsrecht im Index, Shebang, scripts/ im Docker-Abbild, die
Sicherungsskripte in der Compose-Datei. Es ist der zweite Fehler dieser
Art (vorher fehlte scripts/ ganz im Abbild, 1.0.1); beide Male war der
Code richtig und kein Test sah hin, wie die Dateien beim Server
ankommen. Gegenprobe gemacht: mit zurueckgesetztem Recht schlaegt er
fehl und nennt die Datei.

Nebenbei aufgefallen: scripts/version.mjs datierte in UTC. Diese Fassung
waere um 00:08 deutscher Zeit auf den Vortag datiert worden. Rechnet
jetzt in Europe/Berlin.
2026-08-23 00:13:05 +02:00
Florian Wilde c38c988c3c feat: Anmelden mit Passkey
Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg - auf der Anmeldeseite
und bei der erneuten Bestaetigung vor sensiblen Aktionen. Verwaltet unter
"Mein Konto".

Der Passkey ERSETZT den zweiten Faktor, statt zu ihm hinzuzukommen. Das
ist tragbar, weil er zwei Faktoren in sich vereint: Besitz des Geraets
und dessen Entsperrung. Damit das keine Annahme bleibt, wird die
Nutzerverifikation angefordert (userVerification: 'required') und
serverseitig nachgeprueft - eine Antwort ohne sie wird abgewiesen.

TOTP muss vorher eingerichtet sein. Ein Passkey haengt an einem Geraet;
waere er der einzige Weg hinein, koennte ein verlorenes Handy die Akte
schliessen.

Aufbau:
- Die Pruefung laeuft vollstaendig im Credentials-Provider "passkey",
  nicht in der Server Action davor. Ein "war schon geprueft" ueber die
  Grenze zu reichen waere die Stelle, an der sich Anmeldungen faelschen
  lassen.
- Challenges liegen serverseitig und werden per DELETE genau einmal
  verbraucht - die Datenbank entscheidet, wer sie bekommt.
- Bereichskennung und Herkunft kommen aus AUTH_URL, nicht aus einer
  zweiten Konfigurationsquelle.
- Ohne https wird der Knopf gar nicht erst angeboten.
- Rate-Limiting, Kontosperre und Audit-Log gelten unveraendert; die
  Anmeldung nennt den verwendeten Passkey namentlich.

Zwei Anmerkungen zur Migration:
- "prisma migrate diff" wollte zusaetzlich die Trigramm-Indizes aus
  prisma/sql/suche.sql loeschen, die Prisma nicht kennt. Die Zeilen sind
  entfernt und im Migrationskopf vermerkt - ein DROP haette die unscharfe
  Suche stillgelegt, ohne dass etwas fehlgeschlagen waere.
- "prisma format" hat das Schema durchgaengig neu ausgerichtet. Ohne
  Whitespace betrachtet sind es 88 hinzugefuegte Zeilen und keine
  geloeschte.

Geprueft: 21 Einheitstests, drei End-to-End-Tests gegen Chromiums
virtuellen Authenticator (einrichten, anmelden, bestaetigen, entfernen,
Geraet ohne Verifikation abgewiesen), beide Geraeteprofile. 496 Tests,
null Typfehler, Produktionsbau steht.

Nebenbei: Die Zahlen im README waren veraltet (24 statt 33 Modelle, 46
statt 79 Seiten). Sie stehen jetzt mit dem Befehl daneben, mit dem man
sie nachzaehlt.
2026-08-22 18:44:56 +02:00
77 changed files with 9179 additions and 564 deletions
+4
View File
@@ -43,3 +43,7 @@ npm-debug.log*
# Bildschirmfotos entstehen beim Entwickeln neu
screenshots/
daten/dokumente/
# Arbeitsbereiche paralleler Agenten (git worktrees). Enthalten einen
# vollstaendigen zweiten Checkout - der gehoert nicht ins Repository.
.claude/
+431 -1
View File
@@ -26,6 +26,429 @@ Wiederherstellung*.
_Noch nichts._
## [1.5.0] – 2026-08-23
### Hinzugefügt
- **Schreiben als E-Mail übernehmen**
([#5](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/5)).
Vieles geht heute per E-Mail hinaus, nicht per Brief. Im Schreiben-Editor
gibt es jetzt den Umschalter **Als Brief / Als E-Mail**.
Die E-Mail-Fassung wird aus dem — womöglich schon bearbeiteten —
Brieftext abgeleitet: ohne Absenderblock, Anschrift und Datumszeile, die
gehören in den Brief und nicht in die Mail. **Der Bestellungsabsatz
bleibt drin** — ohne ihn kommt die Auskunft nicht. Betreff und Text sind
einzeln kopierbar.
- **Zwei Wege ins Mailprogramm.** Die offene Entscheidung aus dem Issue
(mailto oder .eml) ist mit *beides* beantwortet:
**„Im Mailprogramm öffnen"** (mailto) für kurze Texte — der Knopf
verschwindet, sobald der kodierte Link 1800 Zeichen übersteigt. mailto
übergibt den Text in der Adresszeile, deren Länge nirgends zugesichert
ist; ein stillschweigend halbierter Brief fällt erst beim Empfänger auf.
**„Als E-Mail-Entwurf (.eml)"** immer: eine Datei, die sich im
Mailprogramm als fertiger Entwurf öffnet — Adresse, Betreff und voller
Text garantiert vollständig, mit korrekt kodierten Umlauten. Die Datei
erfindet bewusst keinen Absender: den trägt das Mailprogramm beim
Senden selbst ein.
Beide Knöpfe erscheinen nur, wenn eine E-Mail-Adresse da ist — aus dem
gewählten Kontakt übernommen oder von Hand eingetragen. Ohne Adresse
bleibt das Kopieren.
- **Die Ablage kennt den Unterschied.** Eine als E-Mail abgelegte
Nachricht heißt in der Akte „E-Mail: …" und die PDF-Fassung trägt die
Empfängeradresse im Kopf — abgelegt wird die E-Mail-Fassung, nicht der
Brieftext. In der Akte soll stehen, was tatsächlich hinausging.
### Geprüft
- 20 Einheitstests: die Ableitung der E-Mail-Fassung (nichts geht
stillschweigend verloren, der Bestellungsabsatz bleibt), die
mailto-Längengrenze **nach** der Kodierung, das .eml-Format samt
Umlauten nach RFC 2047 und quoted-printable.
- Vier End-to-End-Tests: Fassung ohne Briefkopf, .eml-Download, kein
Mailknopf ohne Adresse, Ablage als gekennzeichnete E-Mail.
## [1.4.0] – 2026-08-23
### Hinzugefügt
- **Schreiben erzeugen, ausgeben und ablegen**
([#4](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/4)).
Die Anschreiben an Versicherer und Behörden gab es bisher nur als Text
zum Kopieren. Danach war Handarbeit: in Word nachbauen, ausdrucken,
abschicken — und noch einmal von Hand als Dokument in die Akte legen.
Vier Schritte, und der letzte wurde am häufigsten vergessen. Dann fehlt
der Nachweis genau dort, wo er gebraucht wird.
Neu unter **Schreiben**: Text bearbeiten, als **Word-Datei**
herunterladen, **in die Akte legen** und dabei eine **Wiedervorlage**
setzen — in einem Zug.
Der Text bleibt vollständig bearbeitbar. Jeder Fall hat eine
Besonderheit, die in den Brief gehört; ein Formulartext, den man nicht
mehr ändern kann, wird früher oder später falsch abgeschickt.
- **Schreiben ohne Vertrag und ohne Vorgang.** Bisher ging ein Anschreiben
nur zu einem vorhandenen Datensatz. Am Anfang einer Betreuung schreibt
man aber gerade an Stellen, zu denen es noch keinen gibt — man fragt ja
an, um zu erfahren, ob dort überhaupt etwas besteht. Jetzt geht es an
einen **Kontakt** oder an eine **frei getippte Anschrift**, aus der sich
mit einem Klick ein Kontakt anlegen lässt. So wächst das Adressbuch
nebenbei beim Schreiben statt durch Pflege.
- **Die Wiedervorlage entsteht mit.** Rückkaufswert 28 Tage, Kündigung 21,
Sachstandsanfrage 42 — jeweils änderbar, und daneben steht, warum die
Frist so lang ist. Bei einer Sachstandsanfrage wird zusätzlich der letzte
Kontakt am Vorgang nachgezogen, damit *Wartet auf andere* auf „Heute"
stimmt.
### Zwei Entscheidungen, die erklärt gehören
**In die Akte kommt eine PDF-Fassung, heruntergeladen wird eine
Word-Datei.** Der Objektspeicher nimmt nur PDF und Bilder an, geprüft an
den Magic Bytes — eine Word-Datei ist ein ZIP-Archiv, sie durchzulassen
hieße jedes ZIP durchzulassen. Diese Prüfung ist eine bewusste Zusage der
Anwendung; sie für eine Bequemlichkeit aufzuweichen wäre der falsche
Handel. Und sachlich ist PDF für den Nachweis ohnehin das richtige Format:
Es ändert sich nicht mehr.
Daraus folgt eine Einschränkung, die auch in der Oberfläche steht:
**Änderst du die Word-Datei danach noch**, weicht die Akte davon ab. Dann
lieber dort fertig schreiben, den Text zurückkopieren und erst dann
ablegen.
**Der Berichtssatz wurde nicht umgebogen.** Die vorhandene DOCX-Ausgabe
setzt einen Bericht mit Kopfzeilen und Unterschriftenblock; ein Brief
braucht Absender, Anschriftenfeld, Datumszeile und Grußformel. Beides in
eine Funktion zu zwingen hätte beide Ausgaben schlechter gemacht.
### Geprüft
- Zehn Einheitstests, darunter der Gleichschritt der Wiedervorlagefristen:
Sie stehen an zwei Stellen und dürfen nicht auseinanderlaufen, sonst
mahnt die Anwendung zu zwei verschiedenen Terminen.
- Fünf End-to-End-Tests: Entwurf mit Bestellungsabsatz, Vertragsbezug,
Word-Download, Ablage samt Wiedervorlage und Kontakt aus freier
Anschrift.
Zwei Fehler hat erst der Browsertest gefunden — beide in den Tests
selbst: Die Dokumentenliste rendert Tabelle **und** Karten, je nach
Bildschirmbreite ist eine davon versteckt. Ein Treffer auf die
unsichtbare Fassung sieht aus wie ein Fehler in der Anwendung.
## [1.3.0] – 2026-08-23
### Hinzugefügt
- **Dauerzahlungen zu einem Vertrag gelten als belegt**
([#11](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/11)).
Bei einer monatlichen Versicherungsprämie gibt es keinen Beleg — es gibt
einen Vertrag, und der wird einmal abgeschlossen, nicht monatlich
quittiert. Bisher blieb nur ein Freitextfeld pro Buchung: zwölfmal im
Jahr derselbe Satz, mal Anzahl der Verträge.
Eine Buchung lässt sich jetzt einem **Vertrag** zuordnen, und diese
Zuordnung zählt als Nachweis. Bei einem Dauerschuldverhältnis ist der
Vertrag der Beleg: Ein Rechtspfleger will für zwölf gleiche Lastschriften
keine zwölf Quittungen sehen, sondern die Police.
**Die Belegquote weist drei Fälle getrennt aus** — mit Beleg, durch
Vertrag belegt, begründet ohne Beleg. Zusammengeworfen verlöre die Zahl
ihre Aussage.
- **Automatisch beim Import**, wenn die Gegenstelle zu genau *einem*
Vertrag passt. Bei Mehrdeutigkeit geschieht nichts: Hausrat und
Haftpflicht bei derselben Gesellschaft lassen sich nicht auseinanderhalten,
und eine falsche Zuordnung wäre schlimmer als gar keine — sie behauptet
einen Beleg, und niemand sieht noch einmal hin. Automatisch gesetzte
Zuordnungen sind in der Liste als solche gekennzeichnet.
- **Sammelzuordnung** unter *Wiederkehrende Zahlungen*: Liegt zu einer
Gruppe ein Vertrag vor, werden alle ihre Zahlungen mit einem Klick
zugeordnet. Bei drei Jahren Laufzeit sind das 36 Buchungen.
- **Die Rechnungslegung nennt den Vertrag.** Statt „ohne Beleg – …" steht
dort „Dauerzahlung zu Vertrag «Bezeichnung», Nr. …". Die Lücke wird nicht
versteckt, sondern erklärt: Wer nachprüfen will, weiß sofort, welches
Papier er aus dem Ordner holt.
### Die Sicherung dagegen
**Weicht der Betrag um mehr als ein Fünftel vom hinterlegten Beitrag ab,
gilt die Zahlung nicht als belegt**, sondern wird als *Betrag prüfen*
markiert. Sonst wäre die Zuordnung eine Hintertür, durch die auch eine
verzehnfachte Prämie unbemerkt durchginge.
Ein Fünftel ist bewusst großzügig: Beitragsanpassungen und
Versicherungsteuer bewegen sich in dieser Größenordnung, und dabei soll
die Anwendung nicht meckern. Wer die Grenze enger zieht, bekommt täglich
Warnungen und sieht die eine wichtige nicht mehr.
Steht am Vertrag **kein** Beitrag, findet keine Prüfung statt. Die
Zuordnung gilt trotzdem — es wird nur nicht so getan, als wäre geprüft
worden.
Und: **Verschwindet der Vertrag, fällt die Buchung zurück in „Beleg
fehlt".** Ein Nachweis, der auf etwas zeigt, das es nicht mehr gibt, wäre
schlimmer als gar keiner.
### Zu beachten
- **Eine Datenbank-Migration ist nötig**
(`20260823062000_buchung_vertragszahlung`). Sie fügt zwei Spalten hinzu
und ändert nichts an vorhandenen Daten.
- Bestehende Buchungen sind nicht betroffen: Ohne Zuordnung bleibt alles,
wie es war.
### Geprüft
- 23 neue Einheitstests. Die Hälfte prüft, dass die Anerkennung auch
wirklich **wegfällt** — der gefährliche Fehler wäre hier ein zu mildes
Urteil, nicht ein zu strenges.
- Fünf End-to-End-Tests: Sammelzuordnung, Anzeige in der Buchungsliste,
die abweichende Zahlung, die Kachel auf „Heute", und der Rückfall nach
dem Löschen des Vertrags.
Der Browsertest hat zwei Fehler gefunden, die alle anderen Prüfungen
durchgelassen haben — siehe unten.
### Behoben
- **Die Buchungsliste hatte eine eigene, fast gleiche Datenabfrage** neben
der gemeinsamen aus `berichte/gemeinsam.ts`. Als die Dauerzahlungen
dazukamen, wurde nur eine von beiden nachgezogen: In der Liste stand
weiter „Beleg fehlt", obwohl der Vertrag hinterlegt war. Beide benutzen
jetzt dieselbe Abbildung — die kann nicht halb vergessen werden.
## [1.2.0] – 2026-08-23
### Hinzugefügt
- **Wiederkehrende Zahlungen aufspüren**
([#3](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/3)).
Am Anfang einer Betreuung weiß niemand, welche Verträge es gibt — es liegt
kein Ordner bereit, und die betreute Person kann es oft selbst nicht mehr
sagen. Die Antwort steht aber schon im Kontoauszug: **Wer regelmäßig
abbucht, hat einen Vertrag.**
Neu unter *Vermögen & Kasse → Wiederkehrende Zahlungen*: Die importierten
Kontobewegungen werden nach Empfänger gruppiert und nach Jahresbetrag
sortiert. Je Zeile stehen Rhythmus, Betrag, Zeitraum und ob dazu schon ein
Vertrag in der Akte liegt. Aus der Zeile heraus geht es weiter — *Vertrag
anlegen* mit vorbelegter Gesellschaft und vorbelegtem Beitrag, oder *kein
Vertrag* mit Begründung.
Die Seite wertet nur aus. Sie legt von sich aus nichts an; jede Zeile
endet in einer Entscheidung, die ein Mensch trifft.
**Was sie nennt, begründet sie.** Statt „monatlich" zu behaupten, steht
daneben „6 Zahlungen, etwa monatlich" — man soll widersprechen können.
Schwankt der Betrag, wird die Spanne genannt statt eines Mittelwerts, der
Genauigkeit vortäuscht. Und ohne erkennbaren Abstand wird nicht aufs Jahr
hochgerechnet, sondern gezeigt, was tatsächlich geflossen ist.
**Zusammengefasst wird nur, was sicher dasselbe ist:** Groß- und
Kleinschreibung, Satzzeichen, Umlaute und die Rechtsform am Ende.
„Stadtwerke Bamberg" und „Stadtwerke Bayreuth" unterscheiden sich in drei
Zeichen und bleiben zwei Zeilen. Zwei Zeilen zu viel sind harmlos — eine
falsch zusammengefasste Gruppe verbirgt dagegen einen Vertrag, und der
fehlt dann im Vermögensverzeichnis.
- **Einzelne größere Zahlungen** stehen in einem eigenen Abschnitt darunter.
Grund: Die Gruppierung braucht mindestens zwei Zahlungen, eine jährlich
abgebuchte Prämie erscheint bei einem importierten Jahr aber nur einmal —
und ausgerechnet Versicherungen werden oft jährlich gezahlt. Ohne diese
zweite Liste wäre die Suche genau dort blind, wo sie am meisten bringen
soll.
- **Hinweis auf „Heute"**, sobald es unbearbeitete Kandidaten gibt. Er steht
in der Kachel *Unverbuchte Belege*, nicht in einer eigenen: Es ist
dieselbe Frage — wozu gehört diese Zahlung? —, nur eine Ebene höher.
### Zu beachten
- **Eine Datenbank-Migration ist nötig**
(`20260823000500_zahlungsgruppe_geklaert`). Sie läuft beim Start des
Behälters automatisch mit und legt nur eine neue Tabelle an; bestehende
Daten bleiben unberührt.
- Ausgewertet werden **nur Ausgaben der betreuten Person**. Umbuchungen
zwischen eigenen Konten und Auslagen des Betreuers bleiben außen vor —
wer monatlich aufs Sparbuch legt, hat deswegen keinen Vertrag.
### Geprüft
- 37 Einheitstests (`tests/wiederkehrend.test.ts`), die Hälfte davon in der
Gegenrichtung: dass ähnliche, aber verschiedene Namen **nicht**
zusammenfallen.
- Vier End-to-End-Tests gegen einen echten Browser: Erkennung, getrennte
Liste der Einzelzahlungen, Übernahme ins Vertragsformular, Abhaken und
Zurückholen samt Neuladen.
Zwei Fehler hat erst der Browsertest gefunden. Einer davon hätte die
Vorbelegung des Beitrags stillschweigend verschluckt — Typprüfung und
Einheitstests waren grün.
## [1.1.2] – 2026-08-23
### Behoben
- **„Mein Konto" war am Schreibtisch praktisch nicht zu finden**
([#1](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/1)).
Damit kam man dort auch nicht an den Passwortwechsel, den zweiten Faktor
und die frisch eingebaute Passkey-Verwaltung.
Ursache: Die Seitenleiste hatte keine feste Höhe und wuchs mit der Seite
mit. Ihr Fuß — und damit das Benutzermenü — landete deshalb am **Ende der
Seite** statt am unteren Bildschirmrand. Auf „Heute" waren das 1632 Pixel
bei 900 Pixel Fensterhöhe: Man musste bis ganz nach unten scrollen, um
an sein eigenes Konto zu kommen.
Der Fehler sah nach nichts aus. Kein Element fehlte, nichts war kaputt,
die Seite sah richtig aus — und genau deshalb sucht man an der falschen
Stelle.
Die Leiste bleibt jetzt stehen (`sticky`, Fensterhöhe). Kopf,
Betreutenauswahl und Fuß sind immer sichtbar; die Navigation dazwischen
scrollt bei Bedarf in sich selbst. Am Handy war es nie ein Problem —
dort sitzt das Menü in der ohnehin festen Kopfzeile.
### Geprüft
- Fünf End-to-End-Tests (`e2e/benutzermenue.spec.ts`) fragen nicht, ob das
Menü **im DOM** ist, sondern ob es **im sichtbaren Bereich** steht: auf
fünf verschiedenen Seiten, an drei Scrollpositionen, auf einem flachen
Laptopfenster (1280 × 620) und am Handy.
Die Scrollposition in der Mitte ist Absicht. Nur oben und unten zu messen
hätte nicht gereicht: Am Seitenende wird eine mitwachsende Leiste
zufällig doch sichtbar — der Test wäre grün gewesen und der Fehler
trotzdem da. Beide Sichtbarkeitstests sind gegen den kaputten Zustand
gegengeprüft und schlagen dort an.
## [1.1.1] – 2026-08-23
### Behoben
- **`./scripts/update.sh` endete in „Permission denied".** Die
Shell-Skripte lagen ohne Ausführungsrecht im Repository (Modus 644 statt
755). Die Anleitung sagt `./scripts/update.sh` — auf Linux ging genau
das nicht.
Ursache ist der Entwicklungsrechner: Unter Windows steht
`core.filemode=false`, dort merkt git von einem fehlenden
Ausführungsrecht nichts. Aufgefallen ist es erst auf dem Server.
**Wer von 1.1.0 oder älter kommt**, braucht einmalig den Umweg — das
alte Skript ist ja noch das nicht ausführbare:
```bash
bash scripts/update.sh
```
Danach stimmt das Recht, und der Punktschrägstrich funktioniert.
- **`update.sh` setzt das Recht nach dem Umschalten selbst.** Wer Dateien
von Hand kopiert hat — etwa um an das Skript zu kommen, bevor es in der
laufenden Fassung existierte —, verliert es dabei. Ein `chmod +x` nach
dem Umschalten fängt das ab.
- **Das Versionsskript datierte in UTC.** Eine Veröffentlichung nach
22 Uhr deutscher Sommerzeit bekam im Changelog das Datum des Vortages —
diese Fassung hier war der erste Fall. Es rechnet jetzt in
`Europe/Berlin`, wie alles andere in dieser Anwendung auch.
### Geprüft
- Vier neue Prüfungen (`tests/skripte.test.ts`) sehen sich nicht das
Programm an, sondern die **Verpackung**: Liegt jedes `.sh` ausführbar im
Index? Hat es eine Shebang-Zeile? Kommt `scripts/` ins Docker-Abbild?
Sind die Sicherungsskripte in der Compose-Datei eingehängt?
Das ist der zweite Fehler dieser Art — vorher fehlte `scripts/` ganz im
Abbild (1.0.1). Beide Male war der Code richtig und kein Test schlug an,
weil keiner hinsah, wie die Dateien beim Server ankommen. Jetzt tut es
einer. Die Gegenprobe ist gemacht: Mit zurückgesetztem Recht schlägt er
fehl und nennt die Datei.
## [1.1.0] – 2026-08-22
### Hinzugefügt
- **Anmelden mit Passkey.** Auf der Anmeldeseite steht jetzt unter dem
Formular ein zweiter Weg: *Mit Passkey anmelden*. Ein Fingerabdruck, ein
Blick in die Kamera oder die Geräte-PIN — keine E-Mail, kein Passwort,
kein abgetippter Code.
**Der Passkey ersetzt den zweiten Faktor, er kommt nicht zusätzlich.**
Das ist kein Nachlassen: Ein Passkey verlangt den Besitz des Geräts
*und* dessen Entsperrung, das sind dieselben zwei Faktoren wie vorher.
Die Anwendung fordert die Nutzerverifikation ausdrücklich an und weist
eine Antwort ohne sie zurück. Ein Gerät, das auf blosses Antippen hin
signiert, kommt nicht herein.
In einem Punkt ist er dem bisherigen Weg sogar überlegen: Ein Passkey
ist an die Adresse gebunden, unter der er eingerichtet wurde. Auf einer
nachgebauten Anmeldeseite funktioniert er nicht — und Phishing ist der
übliche Weg, an einen TOTP-Code zu kommen.
**Die Authenticator-App muss trotzdem eingerichtet sein**, bevor sich
ein Passkey anlegen lässt. Ein Passkey hängt an einem Gerät. Ginge das
verloren und wäre er der einzige Weg hinein, käme niemand mehr an die
Akte — und darin stehen Unterlagen, die eine betreute Person braucht.
Der Passkey ist die bequeme Tür, nicht die einzige.
Verwaltet werden sie unter **Mein Konto → Passkeys**: einrichten,
benennen, entfernen. Die Liste zeigt, wann ein Gerät zuletzt benutzt
wurde und ob der Schlüssel gesichert ist (iCloud, Google, Bitwarden)
oder nur auf diesem einen Gerät liegt. Einrichten und Entfernen stehen
im Protokoll, jede Anmeldung nennt den verwendeten Passkey namentlich.
- **Bestätigen mit Passkey.** Export, endgültiges Löschen und die
Benutzerverwaltung verlangen eine frische Anmeldung. Auch die geht jetzt
mit dem Passkey. Geprüft wird zusätzlich, dass er zum angemeldeten
Zugang gehört — sonst liesse sich mit dem eigenen Finger eine fremde,
offen stehende Sitzung freischalten. Genau der Fall, gegen den diese
Zwischenfrage gebaut ist.
### Zu beachten
- **Passkeys brauchen `https`.** Der Browser gibt das Verfahren nur in
einem sicheren Kontext frei. Läuft die Anwendung ohne TLS, erscheint der
Knopf gar nicht erst, statt beim Klick mit einer kryptischen Meldung
abzubrechen.
- **`AUTH_URL` muss stimmen — jetzt noch mehr als vorher.** Aus ihr wird
die Kennung abgeleitet, an die der Authenticator den Schlüssel bindet.
Steht dort eine andere Adresse als die im Browser, lässt sich zwar ein
Passkey einrichten, aber nie wieder verwenden.
**Ein Wechsel der Domain macht alle Passkeys unbrauchbar.** Das ist
keine Panne, sondern der Phishingschutz. Nach einem Umzug richtet man
sie neu ein; die Anmeldung mit Passwort und Code bleibt währenddessen
offen.
- **Eine Datenbank-Migration ist nötig** (`20260822160000_passkey_anmeldung`).
Sie läuft beim Start des Behälters automatisch mit. Bestehende Zugänge
sind nicht betroffen: Ohne eingerichteten Passkey ändert sich nichts.
### Geprüft
- 21 neue Prüfungen (`tests/passkey.test.ts`), vor allem für die Herleitung
von Kennung und Herkunft aus `AUTH_URL`: Port, Schrägstrich am Ende,
Unterdomain, `http` gegen `https`, `localhost`. Das ist die Stelle, an
der eine Installation still kaputtginge — dieselbe Fehlerklasse wie
seinerzeit bei `APP_BIND` und `ENC_KEY_V2`.
- Drei End-to-End-Tests gegen einen echten Browser (`e2e/passkey.spec.ts`),
mit Chromiums virtuellem Authenticator: einrichten, abmelden, mit Passkey
anmelden, Benutzung vermerkt, entfernen. Dazu die Bestätigung vor dem
Vollexport und der Fall, dass ein Gerät ohne Nutzerverifikation nicht
hereinkommt.
## [1.0.5] – 2026-08-22
### Hinzugefügt
@@ -392,7 +815,14 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
---
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.5...HEAD
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...HEAD
[1.5.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...v1.5.0
[1.4.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.3.0...v1.4.0
[1.3.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...v1.2.0
[1.1.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.1...v1.1.2
[1.1.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.0...v1.1.1
[1.1.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.5...v1.1.0
[1.0.5]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.4...v1.0.5
[1.0.4]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.3...v1.0.4
[1.0.3]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.2...v1.0.3
+30 -8
View File
@@ -180,6 +180,16 @@ existiert.
./scripts/update.sh
```
> **Kommst du von 1.1.0 oder älter?** Dort lag `update.sh` ohne
> Ausführungsrecht im Repository — `./scripts/update.sh` endet dann in
> „Permission denied". Einmalig hilft:
>
> ```bash
> bash scripts/update.sh
> ```
>
> Ab 1.1.1 ist das Recht gesetzt und der Punktschrägstrich funktioniert.
Das Skript sichert zuerst, zeigt den Changelog zwischen altem und neuem
Stand, fragt nach, schaltet um, baut neu und führt danach die beiden
SQL-Skripte aus. Schlägt die Sicherung fehl, wird nicht aktualisiert.
@@ -293,6 +303,15 @@ entscheiden kann. Was dafür umgesetzt ist:
- Passwörter mit **Argon2id** (19 MiB, t=2, p=1 — OWASP-Empfehlung).
- **Zweiter Faktor ist Pflicht** und lässt sich nicht abschalten. TOTP nach
RFC 6238, kompatibel mit Aegis, 2FAS, Bitwarden, 1Password.
- **Passkeys** (WebAuthn/FIDO2) als zweiter Anmeldeweg — auch für die
erneute Bestätigung vor sensiblen Aktionen. Sie *ersetzen* den zweiten
Faktor, statt zu ihm hinzuzukommen: Nutzerverifikation wird verlangt
(`userVerification: 'required'`) und serverseitig nachgeprüft, ein
Schlüssel ohne sie wird abgewiesen. Damit sind es dieselben zwei
Faktoren — Besitz des Geräts und dessen Entsperrung —, nur an die Domain
gebunden und deshalb nicht abphishbar. TOTP muss trotzdem eingerichtet
sein, bevor sich ein Passkey anlegen lässt: Er hängt an einem Gerät und
darf nicht der einzige Weg hinein sein.
- **Rate-Limiting** am Login, nach E-Mail *und* nach Anschluss. Die Sperre
wird geprüft, bevor Argon2 überhaupt rechnet.
- Die Antwortzeit ist auf eine Mindestdauer gestreckt, damit sie nicht
@@ -559,14 +578,17 @@ Fehler, die während des Baus aufgefallen sind.
## Stand
| | |
|---|---:|
| Zeilen TypeScript/TSX | 71.051 |
| Prisma-Modelle | 24 |
| Seiten | 46 |
| Unit-Tests (Vitest) | 458 |
| End-to-End-Tests (Playwright) | 34 |
| TypeScript-Fehler | 0 |
| | | nachzuzählen mit |
|---|---:|---|
| Zeilen TypeScript/TSX | 78.486 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` |
| Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` |
| Seiten | 81 | `find src/app -name page.tsx \| wc -l` |
| Unit-Tests (Vitest) | 590 | `npm test` |
| End-to-End-Tests (Playwright) | 81 | `npm run test:e2e` — 44 Tests über zwei Geräteprofile, geräteabhängige übersprungen |
| TypeScript-Fehler | 0 | `npm run typecheck` |
Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es
jemandem auffällt — bei den Modellen und Seiten war genau das passiert.
Alle acht Etappen sind umgesetzt. Der Produktionsbuild ist gegen den
fertigen Standalone-Server geprüft: strenge CSP aktiv, keine
+84
View File
@@ -57,6 +57,10 @@ Anschreiben wieder auf.
**Zum Schluss:** Geh auf **Fristen** und klicke einmal auf
*Pflichtfristen ableiten*. Danach stehen deine gesetzlichen Termine drin.
Und wenn du magst: Richte unter **Mein Konto → Passkeys** einen Passkey
ein. Dann meldest du dich künftig mit dem Finger an, statt drei Felder
auszufüllen. Wie das geht, steht gleich im nächsten Abschnitt.
---
## Der Morgen: die Seite „Heute"
@@ -328,6 +332,86 @@ Anwendung jederzeit verlassen. Verlangt eine erneute Anmeldung.
---
## Anmelden mit dem Finger: Passkeys
Drei Felder auszufüllen, nur um kurz eine Frist nachzusehen, ist lästig —
und lästige Sicherheit wird umgangen. Ein Passkey macht daraus einen
Handgriff.
### Einrichten
**Mein Konto → Passkeys.** Gib dem Gerät einen Namen, an dem du es später
wiedererkennst („iPhone", „Laptop Büro"), und klicke auf *Passkey
einrichten*. Dein Gerät fragt nach Fingerabdruck, Gesicht oder PIN —
fertig.
Mach das auf **jedem Gerät**, mit dem du arbeitest. Sie stören sich nicht
gegenseitig.
### Anmelden
Auf der Anmeldeseite steht unter dem Formular *Mit Passkey anmelden*. Ein
Klick, ein Finger, drin. Keine E-Mail, kein Passwort, kein Code.
### Warum das nicht unsicherer ist
Es sieht nach weniger aus, ist aber dasselbe — zweimal:
| | Bisher | Mit Passkey |
|---|---|---|
| Erster Faktor | Passwort (Wissen) | Das Gerät selbst (Besitz) |
| Zweiter Faktor | Code aus der App (Besitz) | Fingerabdruck, Gesicht oder PIN |
Der entscheidende Zusatz: **Ein Passkey ist an die Adresse gebunden**,
unter der du ihn eingerichtet hast. Landest du auf einer nachgebauten
Anmeldeseite, funktioniert er dort schlicht nicht — er lässt sich nicht
abtippen und nicht weitergeben. Genau das ist der übliche Weg, an einen
Code aus der Authenticator-App zu kommen.
Ein Gerät, das den Passkey ohne Fingerabdruck oder PIN freigibt, wird
abgewiesen. Das wäre nur ein Faktor, und dafür ist hier zu viel zu
verlieren.
### Die Authenticator-App bleibt
Du kannst sie nicht abschaffen, und das ist Absicht. **Ein Passkey hängt
an einem Gerät.** Geht das Handy in der Donau baden und wäre der Passkey
der einzige Weg hinein, käme niemand mehr an die Akte — auch der Bezirk
nicht, auch das Gericht nicht.
Deshalb: erst Authenticator-App und Wiederherstellungscodes, dann Passkeys.
Die Anmeldung mit E-Mail, Passwort und Code bleibt daneben immer offen.
### Was in der Liste steht
Unter **Mein Konto → Passkeys** siehst du zu jedem Gerät, wann es
eingerichtet und wann es zuletzt benutzt wurde. **Sieh da gelegentlich
hinein.** Ein Eintrag, den du nicht zuordnen kannst, gehört sofort
entfernt — und danach das Passwort gewechselt.
Steht bei einem Eintrag *gesichert*, wird der Schlüssel vom Anbieter
mitgesichert (iCloud-Schlüsselbund, Google, Bitwarden) und taucht auf
einem neuen Gerät von selbst wieder auf. Ohne diesen Vermerk liegt er nur
auf diesem einen Gerät.
### Wenn ein Gerät verloren geht
1. Auf einem anderen Gerät mit E-Mail, Passwort und Code anmelden.
2. **Mein Konto → Passkeys**, den Eintrag des verlorenen Geräts entfernen.
3. Fertig — der Schlüssel auf dem Gerät ist damit wertlos.
Ein Passkey allein reicht ohnehin niemandem, der das Gerät findet: Ohne
Fingerabdruck oder PIN gibt das Gerät ihn nicht heraus.
### Wenn der Knopf gar nicht erscheint
Dann bietet der Server Passkeys nicht an — das Verfahren verlangt eine
verschlüsselte Verbindung (`https`). Oder der Browser ist zu alt. Melde
dich mit E-Mail, Passwort und Code an; alles andere funktioniert
unverändert.
---
## Am Handy
Die Anwendung ist am Telefon vollwertig bedienbar — sie ist dafür gebaut,
+63 -7
View File
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand
git checkout v1.0.0
git checkout v1.5.0
```
### Kontrolle
```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.0.0
git describe --tags # v1.5.0
```
---
@@ -220,6 +220,12 @@ APP_PORT=3000
> Steht dort etwas anderes, wird das Sitzungs-Cookie für die falsche
> Herkunft gesetzt: Die Anmeldung landet stumm wieder auf dem Formular,
> ohne Fehlermeldung. Das ist der Fehler, den man am längsten sucht.
>
> Seit 1.1.0 hängt noch mehr daran: Aus dieser Adresse wird die Kennung
> abgeleitet, an die Passkeys gebunden werden. Steht sie falsch, lässt
> sich zwar ein Passkey einrichten, aber nie wieder verwenden. Und ein
> späterer Wechsel der Domain macht alle vorhandenen Passkeys
> unbrauchbar — das ist kein Fehler, sondern der Phishingschutz.
**Läuft auf dem Server schon etwas auf Port 3000?** Dann weich aus:
@@ -293,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up
```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"bereit","version":"1.0.0","commit":"..."}
# {"status":"bereit","version":"1.5.0","commit":"..."}
```
Kommt hier nichts, sieh in die Logs:
@@ -643,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash
curl -s http://10.0.0.5:3000/api/gesundheit
# {"status":"bereit","version":"1.0.0",...}
# {"status":"bereit","version":"1.5.0",...}
```
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
@@ -694,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- Unter **Protokoll** siehst du deine eigene Anmeldung.
- Unten links in der Seitenleiste steht `Version 1.0.0 · <commit>`.
- Unten links in der Seitenleiste steht `Version 1.5.0 · <commit>`.
---
@@ -837,6 +843,16 @@ cd /opt/betreuungs-cockpit
./scripts/update.sh
```
> **Kommst du von 1.1.0 oder älter?** Dort lag `update.sh` ohne
> Ausführungsrecht im Repository — `./scripts/update.sh` endet dann in
> „Permission denied". Einmalig hilft:
>
> ```bash
> bash scripts/update.sh
> ```
>
> Ab 1.1.1 ist das Recht gesetzt und der Punktschrägstrich funktioniert.
Das war es. Das Skript nimmt dir die Schritte ab, die man von Hand
vergisst:
@@ -859,7 +875,7 @@ alles so vor wie zuvor.
| Aufruf | Wirkung |
|---|---|
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
| `./scripts/update.sh v1.2.0` | auf eine bestimmte Fassung |
| `./scripts/update.sh v1.5.0` | auf eine bestimmte Fassung |
| `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern |
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
@@ -897,7 +913,7 @@ git fetch --tags
git log --oneline HEAD..origin/main
# 3. Umschalten
git checkout v1.1.0
git checkout v1.5.0
# 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
@@ -1024,6 +1040,46 @@ timedatectl
docker compose exec app date
```
### Der Knopf „Mit Passkey anmelden" erscheint nicht
Passkeys brauchen einen sicheren Kontext. Die Anwendung bietet sie nur an,
wenn `AUTH_URL` auf `https://` steht (oder auf `localhost`) — sonst
erscheint der Knopf bewusst gar nicht erst, statt beim Klick mit einer
kryptischen Browsermeldung abzubrechen.
```bash
docker compose exec app printenv AUTH_URL # muss mit https:// beginnen
```
Steht dort `http://`, korrigiere die `.env` und starte neu:
```bash
docker compose up -d
```
Erscheint der Knopf weiterhin nicht, kann der Browser kein WebAuthn — das
betrifft praktisch nur sehr alte Fassungen.
### Ein Passkey lässt sich einrichten, aber nicht zum Anmelden benutzen
Fast immer stimmt die Adresse nicht überein. Der Schlüssel ist an den
Hostnamen aus `AUTH_URL` gebunden; der Browser vergleicht ihn mit dem, was
in der Adresszeile steht.
```bash
docker compose exec app printenv AUTH_URL
# muss exakt die Adresse sein, die du im Browser eintippst
```
Typische Ursachen: `AUTH_URL` zeigt auf `cockpit.example.de`, aufgerufen
wird aber `www.cockpit.example.de` — oder umgekehrt. Ein Passkey für die
eine Adresse gilt auf der anderen nicht.
**Nach einem Wechsel der Domain** müssen alle Passkeys neu eingerichtet
werden. Die alten Einträge lassen sich unter *Mein Konto → Passkeys*
entfernen. Die Anmeldung mit E-Mail, Passwort und Code funktioniert in der
Zwischenzeit unverändert — man sperrt sich damit also nicht aus.
### Die Texterkennung findet nichts
```bash
+141 -5
View File
@@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre.
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
als wichtiger herausstellt, rutscht nach oben.
Stand: 22.08.2026 · Aktuelle Fassung: **1.0.0** ([CHANGELOG](../CHANGELOG.md))
Stand: 23.08.2026 · Aktuelle Fassung: **1.5.0** ([CHANGELOG](../CHANGELOG.md))
---
@@ -61,9 +61,10 @@ Auszug erzeugt dann doppelte Buchungen.
Der Wert hängt heute am Vermögensposten. In der Vertragsübersicht steht
deshalb kein Betrag — ausgerechnet dort, wo man ihn beim Telefonat mit dem
Versicherer braucht.
Versicherer braucht. Es ist derselbe Wert, um den es im Anschreiben an den
Versicherer geht — siehe 1.2a.
*Aufwand: klein.*
*Aufwand: klein. Issue: [#8](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/8).*
### Genau eine Zielspalte je Dokumentverknüpfung
@@ -120,7 +121,10 @@ Benutzer. Bei einer Betreuung zu zweit ist das die erste Frage.
### Ansprechpartner am Vorgang
Die Sachstandsanfrage kann namentlich anreden, bekommt den Ansprechpartner
mangels Feld aber nicht übergeben.
mangels Feld aber nicht übergeben. Gehört zum Schriftverkehr in 1.2a: dort
entsteht das Schreiben, hier fehlt ihm die Anrede.
*Issue: [#9](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/9).*
### Inventarliste als eigenes Modell
@@ -136,6 +140,131 @@ Jahresbericht und bei einer Höherstufung gebraucht.
---
## 1.2a — Schriftverkehr, der sich nicht wiederholt
**Ziel:** Ein Schreiben wird einmal geschrieben, einmal ausgegeben und
einmal abgelegt — statt viermal angefasst.
Die Anschreiben an Versicherer und Behörden gibt es heute nur als Text zum
Markieren und Kopieren. Was danach kommt, macht man von Hand: in Word
nachbauen, ausdrucken, abschicken und anschließend noch einmal als Dokument
in die Akte legen. Vergessen wird regelmäßig der letzte Schritt — und dann
fehlt der Nachweis genau dort, wo er später gebraucht wird.
Nach der Priorisierungsregel am Ende dieser Datei ist das Kriterium 2: Es
kostet jeden Monat Zeit, und einmal im Jahr — vor Vermögensverzeichnis und
Jahresbericht — besonders viel. Bisher stand das Thema als einzelne Zeile
unter „Später, ohne Datum", also unter „wäre schön, und ehrlich gesagt
vielleicht nie". Das war zu tief gehängt.
Die Punkte bauen aufeinander auf und stehen deshalb in dieser Reihenfolge.
### Verträge überhaupt erst finden
Am Anfang einer Betreuung weiß niemand, welche Verträge es gibt; es liegt
kein Ordner bereit, und die betreute Person kann es oft selbst nicht mehr
sagen. Die Antwort steht aber schon im System: **Wer regelmäßig abbucht,
hat einen Vertrag.** Eine Auswertung der Gegenstellen nach wiederkehrenden
Zahlungen macht aus hunderten Buchungszeilen eine Liste von Gesellschaften,
an die zu schreiben ist. Solange die fehlt, nützt die beste Briefvorlage
nichts — deshalb steht dieser Punkt vor allen anderen.
*Issue: [#3](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/3).*
### Schreiben erzeugen, ablegen, nachhalten
Das Kernstück: eine Word-Datei statt Text zum Kopieren, Ablage als
Ausgangsdokument mit Empfänger und Versanddatum, Wiedervorlage mit
vorbelegter Frist. Dazu Empfänger, zu denen es noch keinen Datensatz gibt —
am Anfang schreibt man ja gerade an Stellen, von denen man erst erfahren
will, ob dort etwas besteht. Der Text bleibt vor der Ausgabe änderbar, weil
jeder Fall eine Besonderheit hat, die in den Brief gehört. Die Bausteine
liegen bereits im Code; was fehlt, ist der durchgehende Weg. Alles Weitere
in diesem Abschnitt setzt darauf auf.
*Issue: [#4](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/4).*
### Dasselbe Schreiben als E-Mail
Vieles geht per Mail raus, nicht per Brief. Dafür müssen Betreff und Text
getrennt vorliegen und Absenderblock, Anschrift und Datumszeile
verschwinden. Auseinandergebaut wird das heute von Hand, und dabei fällt
regelmäßig etwas heraus, das drinbleiben sollte.
*Issue: [#5](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/5).*
### Rückkaufswerte in einem Zug anfragen
Vor Vermögensverzeichnis und Jahresbericht sind es jedes Mal dieselben fünf
bis zehn Briefe an dieselben Gesellschaften — einzeln erzeugt, einzeln
nachgehalten, und bei zwei Dritteln muss nachgefasst werden. Das ist der
Handgriff, der im Jahr am meisten Zeit kostet, und derjenige, der sich am
einfachsten bündeln lässt, sobald ein Schreiben ohnehin auf Knopfdruck
entsteht.
*Issue: [#6](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/6).*
### Eigene Vorlagen
Die Brieftexte stehen fest im Programmcode. Formulierungen, die sich bei
einer bestimmten Stelle bewährt haben, lassen sich nicht hinterlegen, und
für Schreiben ohne mitgelieferte Vorlage — Auskunftsersuchen,
Vollmachtsnachweis, Adressänderung — fängt man bei null an. Wer eine
Vorlage selbst schreibt, vergisst als Erstes den Bestellungsabsatz; ohne
den antwortet keine Versicherung, und dann ist ein Monat vergangen. Die
Vorlagenverwaltung muss ihn deshalb einfordern, statt ihn nur anzubieten.
*Issue: [#7](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/7).*
### Dauerzahlungen gelten als belegt
Ist ein Vertrag erst erfasst, ist er auch der Beleg für die Zahlungen
daraus. Für zwölf gleiche Lastschriften will niemand zwölf Quittungen
sehen, sondern die Police. Heute bleibt dafür nur ein Freitextfeld je
Buchung — stumpfe Arbeit mit einem Ergebnis, das schwächer aussieht als
die Aktenlage tatsächlich ist. Das Feld gehört in dieselbe Migration wie
`Buchung.immobilieId` aus 1.1; zwei Wanderungen an derselben Tabelle wären
Verschwendung.
*Issue: [#11](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/11). Migration: ein Feld, ein Index — gebündelt mit 1.1.*
### Was in 1.1 und 1.2 dazugehört
Zwei Punkte weiter oben sind Teil derselben Sache und bleiben trotzdem
stehen, wo sie stehen: „Rückkaufswert am Vertrag" ist ein möglicher
Fehler und deshalb 1.1, „Ansprechpartner am Vorgang" ein Handgriff und
deshalb 1.2. Gelesen gehören sie hierher — der eine liefert den Wert, um
den das Anschreiben bittet, der andere die namentliche Anrede.
*Issues: [#8](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/8) und [#9](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/9).*
---
## 1.2b — Passkeys abrunden
Der Grundstock steht seit 1.1.0. Was noch fehlt, ist Bequemlichkeit, nicht
Sicherheit.
### Passkey direkt im Anmeldefeld anbieten
Browser können einen Passkey im Ausfüllvorschlag des E-Mail-Feldes
anbieten (*conditional UI*), statt hinter einem eigenen Knopf. Ein Schritt
weniger. Braucht einen zweiten Challenge-Weg und sorgfältiges Abbrechen,
wenn stattdessen doch das Passwort getippt wird.
### Passkey umbenennen
Heute geht nur anlegen und entfernen. Wer sein Gerät „Handy" genannt hat
und inzwischen zwei hat, muss den Passkey neu einrichten.
### Warnen, wenn der letzte Passkey nur auf einem Gerät liegt
Die Liste sagt bereits, ob ein Schlüssel gesichert ist. Wer ausschließlich
ungesicherte Passkeys hat und dazu keine Wiederherstellungscodes mehr,
sollte das deutlicher zu sehen bekommen als in einer Zeile Kleingedrucktem.
---
## 1.3 — Belastbarkeit
**Ziel:** Die Stellen absichern, die bisher nur unter Laborbedingungen
@@ -164,6 +293,14 @@ Monat automatisch durchspielt — Sicherung zurückspielen, Diagnosen im
Klartext prüfen, Testdatenbank verwerfen — wäre der Beweis statt der
Absichtserklärung.
### Passkey-Ablehnung ohne Browser nachstellen
Die Prüfung „Passkey ohne Nutzerverifikation wird abgewiesen" ist im Code
doppelt abgesichert und im Browsertest belegt — dort weist allerdings
schon der Browser ab, der Server kommt gar nicht dran. Ein Test, der die
Antwort von Hand baut und den Browser umgeht, würde den serverseitigen
Rückhalt tatsächlich beweisen.
### Barrierefreiheits-Durchgang mit einem Screenreader
Semantik, Fokusreihenfolge und Kontraste sind beim Bauen beachtet und in
@@ -221,7 +358,6 @@ Sinnvoll, aber nicht dringend:
- **Kalendarische Wiedervorlage per E-Mail als Wochenübersicht** statt
einzelner Erinnerungen.
- **Vorlagen für eigene Anschreiben**, über die mitgelieferten hinaus.
- **Import eines Grundbuchauszugs** als strukturierte Daten statt als PDF.
- **Mehrere Betreuungsgerichte** je betreuter Person (bei Umzug).
- **Zwei-Personen-Prinzip** für besonders sensible Aktionen — technisch
+117
View File
@@ -0,0 +1,117 @@
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
import { anmelden } from './hilfen'
/**
* Das Benutzermenü muss erreichbar sein, ohne danach zu suchen.
*
* Anlass ist Issue #1: Am Schreibtisch war „Mein Konto" praktisch nicht
* zu finden — und damit auch nicht der Passwortwechsel, der zweite Faktor
* und die Passkey-Verwaltung. Die Seitenleiste hatte keine feste Höhe und
* wuchs mit der Seite mit; ihr Fuß landete deshalb am Ende der Seite statt
* am unteren Bildschirmrand. Auf `/heute` waren das 1632 Pixel bei 900
* Pixel Fensterhöhe.
*
* Der Fehler sah nach nichts aus: Kein Element fehlte, nichts war kaputt,
* die Seite sah richtig aus. Man musste nur ans Seitenende scrollen — und
* darauf kommt niemand, der eine Seitenleiste vor sich hat.
*
* Geprüft wird deshalb nicht „ist das Element im DOM", sondern „steht es
* im sichtbaren Bereich".
*/
/** Der Auslöser des Benutzermenüs in der Seitenleiste am Schreibtisch. */
function ausloeserInSeitenleiste(seite: Page) {
return seite.locator('aside button[aria-haspopup="menu"]')
}
/** Steht das Element vollständig im Fenster, ohne zu scrollen? */
async function imSichtbarenBereich(seite: Page, auswahl: string): Promise<boolean> {
return seite.evaluate((sel) => {
const el = document.querySelector(sel)
if (!el) return false
const k = el.getBoundingClientRect()
return k.top >= 0 && k.bottom <= window.innerHeight && k.height > 0
}, auswahl)
}
test.describe('Benutzermenü am Schreibtisch', () => {
test.skip(({ isMobile }) => Boolean(isMobile), 'Am Handy sitzt es in der Kopfzeile.')
test('steht auch auf langen Seiten im Bild, ohne zu scrollen', async ({ page }) => {
await anmelden(page)
// Seiten, die laenger sind als das Fenster - genau dort trat der
// Fehler auf. /fristen ist als Gegenprobe kurz.
for (const ziel of ['/heute', '/vorgaenge', '/dokumente', '/finanzen', '/fristen']) {
await page.goto(ziel)
await page.waitForLoadState('networkidle')
const sichtbar = await imSichtbarenBereich(page, 'aside button[aria-haspopup="menu"]')
expect(sichtbar, `Benutzermenü nicht im Bild auf ${ziel}`).toBe(true)
}
})
test('bleibt an jeder Scrollposition stehen', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
await page.waitForLoadState('networkidle')
// Bewusst auch die Mitte pruefen. Nur oben und unten zu messen genuegt
// nicht: Eine mitwachsende Leiste ist am Seitenende zufaellig doch
// sichtbar - der Test waere dann gruen und der Fehler trotzdem da.
for (const anteil of [0, 0.5, 1]) {
await page.evaluate((a) => {
const hoehe = document.documentElement.scrollHeight - window.innerHeight
window.scrollTo(0, hoehe * a)
}, anteil)
await page.waitForTimeout(250)
const sichtbar = await imSichtbarenBereich(page, 'aside button[aria-haspopup="menu"]')
expect(sichtbar, `Benutzermenü nicht im Bild bei ${anteil * 100}% Scrollposition`).toBe(true)
}
})
test('führt in zwei Klicks nach „Mein Konto"', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
await ausloeserInSeitenleiste(page).click()
await page.getByRole('menuitem', { name: 'Mein Konto' }).click()
await page.waitForURL('**/konto')
// Und dort steht auch, worum es im Issue ging.
await expect(page.getByRole('heading', { name: 'Mein Konto' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Passwort ändern' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Passkeys' })).toBeVisible()
})
test('bleibt auf einem flachen Fenster erreichbar', async ({ page }) => {
// Ein Laptop mit 1280x620 ist knapp: Kopf, Betreutenauswahl,
// Navigation und Fuss muessen zusammen hineinpassen. Die Navigation
// dazwischen darf scrollen, der Fuss nicht verschwinden.
await anmelden(page)
await page.setViewportSize({ width: 1280, height: 620 })
await page.goto('/heute')
await page.waitForLoadState('networkidle')
expect(await imSichtbarenBereich(page, 'aside button[aria-haspopup="menu"]')).toBe(true)
})
})
test.describe('Benutzermenü am Handy', () => {
test.skip(({ isMobile }) => !isMobile, 'Am Schreibtisch sitzt es in der Seitenleiste.')
test('steht in der Kopfzeile und bleibt beim Scrollen stehen', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
await page.waitForLoadState('networkidle')
expect(await imSichtbarenBereich(page, 'header button[aria-haspopup="menu"]')).toBe(true)
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight))
await page.waitForTimeout(300)
expect(await imSichtbarenBereich(page, 'header button[aria-haspopup="menu"]')).toBe(true)
})
})
+176
View File
@@ -0,0 +1,176 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Dauerzahlungen zu einem Vertrag — gegen die echte Anwendung.
*
* Die Einheitstests nageln die Belegzustände fest. Hier geht es um das, was
* sie nicht sehen: Kommt die Zuordnung in der Datenbank an, verschwindet die
* Buchung wirklich aus „Beleg fehlt", und trägt die abweichende Zahlung
* sichtbar die Warnung?
*
* Der Test legt Vertrag und Buchungen selbst an und räumt sie weg.
*/
const GESELLSCHAFT = 'Prüfhausen Rentenversicherung'
const VERTRAG = 'Prüfhausen Rente (Test)'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.buchung.deleteMany({ where: { gegenstelle: GESELLSCHAFT } })
await db.vertrag.deleteMany({ where: { bezeichnung: VERTRAG } })
})
}
/**
* Sechs passende Monatsraten plus eine, die zehnmal so hoch ist.
* Die letzte ist der eigentliche Prüffall.
*/
async function testdatenAnlegen(): Promise<void> {
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
const vertrag = await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: VERTRAG,
gesellschaft: GESELLSCHAFT,
art: 'RENTENVERSICHERUNG_PRIVAT',
beitragCent: 4250n,
rhythmus: 'MONATLICH',
},
select: { id: true },
})
const konto = await db.konto.findFirst({
where: { betreuteId: person.id, geloeschtAm: null },
select: { id: true },
})
const gemeinsam = {
betreuteId: person.id,
kontoId: konto?.id ?? null,
richtung: 'AUSGABE' as const,
kategorie: 'VERSICHERUNG' as const,
betrifftBetreute: true,
gegenstelle: GESELLSCHAFT,
}
for (let monat = 1; monat <= 6; monat++) {
await db.buchung.create({
data: {
...gemeinsam,
datum: new Date(Date.UTC(2026, monat - 1, 1)),
betragCent: 4250n,
},
})
}
// Die Ausreißerin: schon zugeordnet, aber zehnmal so hoch.
await db.buchung.create({
data: {
...gemeinsam,
datum: new Date(Date.UTC(2026, 6, 1)),
betragCent: 42_500n,
vertragId: vertrag.id,
},
})
}
})
}
test.describe('Dauerzahlungen', () => {
test.beforeAll(async () => {
await aufraeumen()
await testdatenAnlegen()
})
test.afterAll(async () => {
await aufraeumen()
})
test('ordnet alle Zahlungen einer Gruppe in einem Schritt zu', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
// Der Vertrag liegt vor, also steht die Gruppe unter „schon zugeordnet".
const eintrag = page.locator('li', { hasText: VERTRAG }).first()
await expect(eintrag).toBeVisible()
// Sechs offene Zahlungen — die siebte hängt schon am Vertrag.
const knopf = eintrag.getByRole('button', { name: /6 Zahlungen als belegt zuordnen/ })
await expect(knopf).toBeVisible()
await knopf.click()
// Die Rückmeldung ist der neue Zustand, nicht eine Meldung: Sind alle
// Zahlungen zugeordnet, verschwindet der Knopf und die Gruppe meldet
// sich als vollständig.
await expect(eintrag).toContainText('Alle 7 Zahlungen sind dem Vertrag zugeordnet')
await expect(eintrag.getByRole('button', { name: /als belegt zuordnen/ })).toHaveCount(0)
})
test('zeigt die Zahlungen danach als durch Vertrag belegt', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/buchungen')
const zeilen = page.locator('tr', { hasText: GESELLSCHAFT })
await expect(zeilen.first()).toBeVisible()
// Die passenden Raten gelten als belegt …
await expect(page.getByText('durch Vertrag belegt').first()).toBeVisible()
// … und keine davon steht mehr als „Beleg fehlt".
const zeileMitFehlt = page.locator('tr', { hasText: GESELLSCHAFT }).filter({
hasText: 'Beleg fehlt',
})
await expect(zeileMitFehlt).toHaveCount(0)
})
test('markiert die abweichende Zahlung zur Prüfung, statt sie durchzuwinken', async ({
page,
}) => {
// Die Zusage, auf der alles steht: Die Zuordnung darf keine Hintertür
// sein, durch die eine verzehnfachte Prämie unbemerkt als belegt gilt.
await anmelden(page)
await page.goto('/finanzen/buchungen')
const ausreisser = page.locator('tr', { hasText: '425,00' })
await expect(ausreisser).toContainText('Betrag prüfen')
})
test('nimmt die Zuordnung aus der Kachel auf „Heute“ heraus', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
// Die sechs zugeordneten Zahlungen dürfen dort nicht mehr auftauchen.
const kachel = page.locator('section, div').filter({ hasText: 'Unverbuchte Belege' }).last()
await expect(kachel).not.toContainText(GESELLSCHAFT)
})
test('fällt zurück in „Beleg fehlt“, wenn der Vertrag verschwindet', async ({ page }) => {
// Ein Nachweis, der auf etwas zeigt, das es nicht mehr gibt, wäre
// schlimmer als gar keiner.
await mitDb(async (db) => {
await db.vertrag.deleteMany({ where: { bezeichnung: VERTRAG } })
})
await anmelden(page)
await page.goto('/finanzen/buchungen')
const zeilen = page.locator('tr', { hasText: GESELLSCHAFT })
await expect(zeilen.first()).toContainText('Beleg fehlt')
})
})
+123
View File
@@ -0,0 +1,123 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Schreiben als E-Mail — gegen die echte Anwendung.
*
* Geprüft wird der Weg, den die Einheitstests nicht sehen: Umschalten auf
* E-Mail, die abgeleitete Fassung ohne Briefkopf, der .eml-Download und
* die Ablage, die das Schreiben als E-Mail kennzeichnet.
*/
const KONTAKT = 'Prüfhausen Versicherung (E-Mail-Test)'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.dokument.deleteMany({ where: { empfaenger: { contains: 'Prüfhausen' } } })
await db.frist.deleteMany({ where: { titel: { contains: 'Antwort abwarten' } } })
await db.kontakt.deleteMany({ where: { name: { contains: 'Prüfhausen' } } })
})
}
test.describe('Schreiben als E-Mail', () => {
test.beforeAll(async () => {
await aufraeumen()
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
await db.kontakt.create({
data: {
betreuteId: person.id,
name: KONTAKT,
organisation: KONTAKT,
rolle: 'SONSTIGES',
email: 'service@pruefhausen.example',
},
})
}
})
})
test.afterAll(async () => {
await aufraeumen()
})
test('leitet die E-Mail-Fassung ohne Briefkopf ab, mit Bestellungsabsatz', async ({
page,
}) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
// Kontakt wählen - bringt die E-Mail-Adresse mit.
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
await expect(page.locator('input#empfaengerEmail')).toHaveValue(
'service@pruefhausen.example',
)
await page.getByRole('button', { name: 'Als E-Mail' }).click()
const mailtext = await page.locator('textarea#emailText').inputValue()
// Beginnt mit der Anrede - kein Absenderblock, keine Datumszeile.
expect(mailtext.startsWith('Sehr geehrte')).toBe(true)
expect(mailtext).not.toMatch(/^\d{1,2}\.\d{1,2}\.\d{4}$/m)
// Der Nachweis der Bestellung bleibt drin.
expect(mailtext).toMatch(/Betreuungsgericht|bestellt/i)
})
test('lädt den .eml-Entwurf herunter', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
await page.getByRole('button', { name: 'Als E-Mail' }).click()
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Als E-Mail-Entwurf (.eml)' }).click(),
])
expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_.*\.eml$/)
})
test('zeigt ohne Adresse keine Mailprogramm-Knöpfe', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.getByRole('button', { name: 'Als E-Mail' }).click()
// Kopieren geht immer, die Programm-Knöpfe nicht.
await expect(page.getByRole('button', { name: 'Text kopieren' }).last()).toBeVisible()
await expect(page.getByRole('button', { name: 'Als E-Mail-Entwurf (.eml)' })).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Im Mailprogramm öffnen' })).toHaveCount(0)
await expect(page.getByText(/Trag oben eine E-Mail-Adresse ein/)).toBeVisible()
})
test('legt die E-Mail als solche gekennzeichnet in die Akte', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
await page.locator('input#betreff').fill('Prüfhausen Testanfrage')
await page.getByRole('button', { name: 'Als E-Mail' }).click()
await page.getByRole('button', { name: 'In die Akte legen' }).click()
await expect(page.getByText('Das Schreiben liegt in der Akte.')).toBeVisible({
timeout: 30_000,
})
// In der Akte als E-Mail erkennbar, nicht als Brief.
await page.goto('/dokumente?richtung=AUSGANG')
await expect(page.locator('body')).toContainText('E-Mail: Prüfhausen Testanfrage')
})
})
+188
View File
@@ -0,0 +1,188 @@
import { expect, test } from '@playwright/test'
import type { BrowserContext, CDPSession, Page } from '@playwright/test'
import { anmelden, fehlversucheLoeschen } from './hilfen'
/**
* Passkeys gegen einen echten Browser.
*
* WebAuthn laesst sich nicht sinnvoll mit Einheitstests pruefen: Was zaehlt,
* passiert zwischen Browser und Authenticator. Chromium bringt dafuer einen
* virtuellen Authenticator mit, der ueber das Debugprotokoll gesteuert wird -
* er signiert echt, nur ohne Fingerabdrucksensor.
*
* Damit laesst sich der Ablauf vollstaendig durchspielen: einrichten,
* abmelden, mit Passkey anmelden, entfernen. Und, wichtiger, der Fall, den
* die Zusage dieser Anwendung traegt - ein Geraet OHNE Nutzerverifikation
* darf nicht hereinkommen.
*/
type VirtuellerAuthenticator = {
cdp: CDPSession
id: string
}
async function authenticatorAnlegen(
kontext: BrowserContext,
seite: Page,
verifiziert = true,
): Promise<VirtuellerAuthenticator> {
const cdp = await kontext.newCDPSession(seite)
await cdp.send('WebAuthn.enable')
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
// "internal" = im Geraet verbaut, wie Face ID oder Windows Hello.
transport: 'internal',
// Auffindbarer Schluessel - Voraussetzung fuer die Anmeldung ohne
// vorherige Eingabe der E-Mail-Adresse.
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: verifiziert,
automaticPresenceSimulation: true,
},
})
return { cdp, id: authenticatorId }
}
async function passkeyEinrichten(seite: Page, name: string): Promise<void> {
await seite.goto('/konto')
await seite.getByLabel('Neues Gerät').fill(name)
await seite.getByRole('button', { name: 'Passkey einrichten' }).click()
await expect(seite.getByText(`„${name}“ ist eingerichtet.`)).toBeVisible()
}
test.describe('Passkey', () => {
test.beforeEach(async () => {
await fehlversucheLoeschen()
})
test('richtet einen Passkey ein und meldet sich anschliessend damit an', async ({
page,
context,
}) => {
const authenticator = await authenticatorAnlegen(context, page)
// --- 1. Auf dem gewoehnlichen Weg herein --------------------------------
await anmelden(page)
// --- 2. Passkey einrichten ---------------------------------------------
await passkeyEinrichten(page, 'Testgerät')
// Er steht in der Liste, mit Datum und dem Hinweis "noch nicht benutzt".
const eintrag = page.locator('li', { hasText: 'Testgerät' })
await expect(eintrag).toBeVisible()
await expect(eintrag).toContainText('noch nicht benutzt')
// --- 3. Abmelden --------------------------------------------------------
await context.clearCookies()
await page.goto('/anmelden')
await expect(page.getByRole('button', { name: 'Mit Passkey anmelden' })).toBeVisible()
// --- 4. Mit Passkey anmelden -------------------------------------------
await Promise.all([
page.waitForURL((url) => !url.pathname.startsWith('/anmelden'), { timeout: 45_000 }),
page.getByRole('button', { name: 'Mit Passkey anmelden' }).click(),
])
// Kein zweiter Schritt: Der Passkey IST der zweite Faktor. Wer hier auf
// der Einrichtungsseite fuer TOTP landet, hat einen Fehler im Ablauf.
expect(new URL(page.url()).pathname).not.toContain('/zwei-faktor')
// --- 5. Die Benutzung ist vermerkt -------------------------------------
await page.goto('/konto')
await expect(page.locator('li', { hasText: 'Testgerät' })).toContainText('zuletzt benutzt')
// --- 6. Wieder entfernen, damit der naechste Lauf sauber startet -------
await page.locator('li', { hasText: 'Testgerät' }).getByRole('button').click()
await page.getByRole('button', { name: 'Wirklich entfernen' }).click()
await expect(page.getByText('„Testgerät“ wurde entfernt.')).toBeVisible()
await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', {
authenticatorId: authenticator.id,
})
})
test('laesst ein Geraet ohne Nutzerverifikation nicht herein', async ({ page, context }) => {
// Das ist die Zusage, auf der alles steht: Ein Passkey darf den zweiten
// Faktor nur ersetzen, wenn das Geraet den Benutzer wirklich verifiziert
// hat - Fingerabdruck, Gesicht oder PIN. Ein Schluessel, der auf blosses
// Antippen hin signiert, ist ein Faktor, kein zweiter.
//
// Was dieser Test belegt: Die Anforderung erreicht das Geraet, und ein
// Geraet ohne Verifikation kommt nicht herein. Abgewiesen wird dabei
// schon vom Browser - im Protokoll steht deshalb kein fehlgeschlagener
// Versuch. Die Pruefung auf dem Server (requireUserVerification und die
// ausdrueckliche Abfrage von userVerified in anmeldungPruefen) ist der
// Rueckhalt fuer den Fall, dass jemand die Antwort von Hand baut und den
// Browser umgeht. Der laesst sich hier nicht nachstellen.
const authenticator = await authenticatorAnlegen(context, page, true)
await anmelden(page)
await passkeyEinrichten(page, 'Ungesichertes Gerät')
// Jetzt die Verifikation abschalten - so verhaelt sich ein Geraet ohne
// eingerichtete Bildschirmsperre.
await authenticator.cdp.send('WebAuthn.setUserVerified', {
authenticatorId: authenticator.id,
isUserVerified: false,
})
await context.clearCookies()
await page.goto('/anmelden')
await page.getByRole('button', { name: 'Mit Passkey anmelden' }).click()
// Entscheidend ist nicht der Wortlaut, sondern: Es geht nicht herein.
await expect(page.getByRole('alert')).toBeVisible({ timeout: 30_000 })
await page.waitForTimeout(1000)
expect(new URL(page.url()).pathname).toBe('/anmelden')
// Aufraeumen: wieder verifizieren, anmelden, Passkey entfernen.
await authenticator.cdp.send('WebAuthn.setUserVerified', {
authenticatorId: authenticator.id,
isUserVerified: true,
})
await fehlversucheLoeschen()
await anmelden(page)
await page.goto('/konto')
await page.locator('li', { hasText: 'Ungesichertes Gerät' }).getByRole('button').click()
await page.getByRole('button', { name: 'Wirklich entfernen' }).click()
await expect(page.getByText('„Ungesichertes Gerät“ wurde entfernt.')).toBeVisible()
await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', {
authenticatorId: authenticator.id,
})
})
test('bestaetigt eine sensible Aktion mit dem Passkey statt mit Passwort und Code', async ({
page,
context,
}) => {
const authenticator = await authenticatorAnlegen(context, page)
await anmelden(page)
await passkeyEinrichten(page, 'Bestätigungsgerät')
// Der Vollexport verlangt eine frische Anmeldung.
await page.goto('/berichte/export')
await page.waitForURL(/\/(bestaetigen|berichte)/)
if (new URL(page.url()).pathname === '/bestaetigen') {
await Promise.all([
page.waitForURL((url) => url.pathname !== '/bestaetigen', { timeout: 45_000 }),
page.getByRole('button', { name: 'Mit Passkey bestätigen' }).click(),
])
expect(new URL(page.url()).pathname).toContain('/berichte/export')
}
await page.goto('/konto')
await page.locator('li', { hasText: 'Bestätigungsgerät' }).getByRole('button').click()
await page.getByRole('button', { name: 'Wirklich entfernen' }).click()
await expect(page.getByText('„Bestätigungsgerät“ wurde entfernt.')).toBeVisible()
await authenticator.cdp.send('WebAuthn.removeVirtualAuthenticator', {
authenticatorId: authenticator.id,
})
})
})
+179
View File
@@ -0,0 +1,179 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Schreiben erzeugen — gegen die echte Anwendung.
*
* Der eigentliche Gewinn dieses Issues ist nicht der Brieftext (den gab es
* schon), sondern was danach passiert: Word-Datei, Ablage als
* Ausgangsdokument, Wiedervorlage. Genau dieser Teil lässt sich mit
* Einheitstests nicht prüfen — er berührt Objektspeicher, Datenbank und
* Download.
*/
const VERTRAG = 'Prüfhausen Police (Test Schreiben)'
const GESELLSCHAFT = 'Prüfhausen Lebensversicherung'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.dokument.deleteMany({ where: { titel: { contains: 'Rückkaufswert' } } })
await db.dokument.deleteMany({ where: { empfaenger: { contains: 'Prüfhausen' } } })
await db.frist.deleteMany({ where: { titel: { contains: 'Antwort abwarten' } } })
await db.kontakt.deleteMany({ where: { name: { contains: 'Prüfhausen' } } })
await db.vertrag.deleteMany({ where: { bezeichnung: VERTRAG } })
})
}
async function vertragAnlegen(): Promise<void> {
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: VERTRAG,
gesellschaft: GESELLSCHAFT,
art: 'LEBENSVERSICHERUNG',
beitragCent: 4250n,
rhythmus: 'MONATLICH',
beginnAm: new Date(Date.UTC(2015, 0, 1)),
},
})
}
})
}
/**
* Findet die Vertrags-ID der AKTIVEN betreuten Person.
*
* Welche Person aktiv ist, haengt an einem Cookie — die Datenbank allein
* weiss es nicht. Der Weg ueber die Vertragsliste ist zugleich der Weg,
* den ein Mensch nimmt.
*/
async function vertragIdAusOberflaeche(page: import('@playwright/test').Page): Promise<string> {
await page.goto('/vertraege')
const link = page.getByRole('link', { name: new RegExp(VERTRAG.replace(/[()]/g, '.')) }).first()
await expect(link).toBeVisible()
const ziel = await link.getAttribute('href')
return (ziel ?? '').split('/').pop() ?? ''
}
test.describe('Schreiben erzeugen', () => {
test.beforeAll(async () => {
await aufraeumen()
await vertragAnlegen()
})
test.afterAll(async () => {
await aufraeumen()
})
test('baut den Entwurf mit Bestellungsabsatz und lässt ihn bearbeiten', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
const brief = page.locator('textarea#text')
await expect(brief).toBeVisible()
const text = await brief.inputValue()
// Ohne den Nachweis der Vertretungsmacht antwortet keine Stelle —
// deshalb steht er auch im freien Entwurf drin.
expect(text).toMatch(/Betreuungsgericht|bestellt/i)
// Und die Lücken sind sichtbar, nicht stillschweigend leer.
expect(text).toContain('[')
await brief.fill(`${text}\n\nZusatz aus dem Test.`)
expect(await brief.inputValue()).toContain('Zusatz aus dem Test.')
})
test('erzeugt aus dem Vertrag heraus einen Rückkaufswert-Entwurf', async ({ page }) => {
await anmelden(page)
const vertragId = await vertragIdAusOberflaeche(page)
expect(vertragId).not.toBe('')
await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`)
await expect(page.locator('input#empfaengerName')).toHaveValue(GESELLSCHAFT)
await expect(page.locator('input#betreff')).not.toHaveValue('')
// Die Wiedervorlage ist vorbelegt — 28 Tage.
await expect(page.locator('input#wiedervorlageAm')).not.toHaveValue('')
const text = await page.locator('textarea#text').inputValue()
expect(text).toContain('Rückkaufswert')
})
test('liefert die Word-Datei aus', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('input#empfaengerName').fill('Prüfhausen Amt')
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Als Word-Datei' }).click(),
])
// Sprechender, sortierbarer Name mit Datum vorn.
expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_.*\.docx$/)
expect(download.suggestedFilename()).toContain('Pruefhausen')
})
test('legt das Schreiben in die Akte und setzt die Wiedervorlage', async ({ page }) => {
await anmelden(page)
const vertragId = await vertragIdAusOberflaeche(page)
await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`)
await page.getByRole('button', { name: 'In die Akte legen' }).click()
await expect(page.getByText('Das Schreiben liegt in der Akte.')).toBeVisible({
timeout: 30_000,
})
await expect(page.getByText(/Wiedervorlage zum/)).toBeVisible()
// Und es steht wirklich in der Dokumentenliste, als Ausgang.
//
// Bewusst ohne tr/li: Die Liste rendert Tabelle UND Karten, je nach
// Bildschirmbreite ist eine davon versteckt. Ein Treffer auf die
// unsichtbare Fassung sähe wie ein Fehler aus.
await page.goto('/dokumente?richtung=AUSGANG')
// Die Zusammenfassungszeile gibt es nur einmal — anders als den Titel,
// der in Tabelle UND Kartenliste steht.
await expect(page.getByText('1 Dokument passen zu den Filtern')).toBeVisible()
await expect(page.locator('body')).toContainText('Auskunft über Rückkaufswert')
await expect(page.locator('body')).toContainText('Ausgang')
// Die Wiedervorlage steht in der Fristenliste.
await page.goto('/fristen')
await expect(page.getByText(/Antwort abwarten/).first()).toBeVisible()
})
test('legt aus einer freien Anschrift einen Kontakt an', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('input#empfaengerName').fill('Prüfhausen Sozialamt')
await page.locator('textarea#empfaengerAnschrift').fill('Musterweg 3\n96450 Coburg')
await page.getByRole('button', { name: 'Als Kontakt merken' }).click()
await expect(page.getByText(/steht jetzt in den Kontakten/)).toBeVisible()
// Beim nächsten Mal steht er zur Auswahl.
await page.goto('/schreiben/neu?art=FREI')
await expect(page.locator('select#kontaktAuswahl')).toContainText('Prüfhausen Sozialamt')
})
})
+181
View File
@@ -0,0 +1,181 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Wiederkehrende Zahlungen gegen die echte Anwendung.
*
* Die Einheitstests nageln die Erkennung fest (tests/wiederkehrend.test.ts).
* Hier geht es um das, was sie nicht sehen können: Kommt die Auswertung mit
* Daten aus der Datenbank überein, führt die Zeile wirklich ins
* Vertragsformular, und überlebt ein Abhaken das Neuladen?
*
* Der Test legt sich seine Buchungen selbst an und räumt sie hinterher weg.
* Er hängt damit nicht am Demofall — der hat heute gar keine Buchungen, und
* ein Test, der auf fremde Daten hofft, wird irgendwann grundlos rot.
*/
/** Eigener Name, damit das Aufräumen nichts Fremdes trifft. */
const EMPFAENGER = 'Prüfhausen Lebensversicherung AG'
const EMPFAENGER_ZWEITE_SCHREIBWEISE = 'PRUEFHAUSEN LEBENSVERSICHERUNG AG'
const EINZELNE = 'Prüfhausen Feuersozietät'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
/**
* Legt für JEDE betreute Person dieselben Buchungen an.
*
* Welche Person nach dem Anmelden aktiv ist, hängt an einem Cookie. Statt
* das im Test nachzubauen, bekommen alle dieselben Daten — es sind ein paar
* Zeilen, und der Test wird dadurch unabhängig von der Reihenfolge.
*/
async function testdatenAnlegen(): Promise<void> {
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
const konto = await db.konto.findFirst({
where: { betreuteId: person.id, geloeschtAm: null },
select: { id: true },
})
const gemeinsam = {
betreuteId: person.id,
kontoId: konto?.id ?? null,
richtung: 'AUSGABE' as const,
kategorie: 'VERSICHERUNG' as const,
betrifftBetreute: true,
}
// Sechs monatliche Abbuchungen, zwei davon in anderer Schreibweise.
for (let monat = 1; monat <= 6; monat++) {
await db.buchung.create({
data: {
...gemeinsam,
datum: new Date(Date.UTC(2026, monat - 1, 1)),
betragCent: 4250n,
gegenstelle: monat > 4 ? EMPFAENGER_ZWEITE_SCHREIBWEISE : EMPFAENGER,
},
})
}
// Eine einzelne größere Zahlung - der Fall "Jahresbeitrag".
await db.buchung.create({
data: {
...gemeinsam,
datum: new Date(Date.UTC(2026, 2, 15)),
betragCent: 48_000n,
gegenstelle: EINZELNE,
},
})
}
})
}
async function testdatenEntfernen(): Promise<void> {
await mitDb(async (db) => {
await db.buchung.deleteMany({
where: { gegenstelle: { in: [EMPFAENGER, EMPFAENGER_ZWEITE_SCHREIBWEISE, EINZELNE] } },
})
await db.zahlungsgruppeGeklaert.deleteMany({
where: { anzeigename: { contains: 'Prüfhausen' } },
})
})
}
test.describe('Wiederkehrende Zahlungen', () => {
test.beforeAll(async () => {
await testdatenEntfernen()
await testdatenAnlegen()
})
test.afterAll(async () => {
await testdatenEntfernen()
})
test('findet die monatliche Abbuchung und fasst die Schreibweisen zusammen', async ({
page,
}) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
const zeile = page.locator('tr', { hasText: 'Prüfhausen Lebensversicherung' }).first()
await expect(zeile).toBeVisible()
// Erkannter Rhythmus samt Begründung - nicht nur das Ergebnis.
await expect(zeile).toContainText('monatlich')
await expect(zeile).toContainText('6 Zahlungen')
// 42,50 EUR mal zwölf.
await expect(zeile).toContainText('510,00')
// Zwei Schreibweisen desselben Empfängers, in einer Zeile.
await expect(zeile).toContainText('Schreibweisen zusammengefasst')
// Und angezeigt wird die lesbare, nicht die geschriene aus dem Auszug.
await expect(zeile).toContainText(EMPFAENGER)
})
test('listet die einzelne größere Zahlung getrennt', async ({ page }) => {
// Die Lücke der Gruppierung: Ein Jahresbeitrag erscheint nur einmal.
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
// Die Überschrift steht, und die Zahlung dazu in einer eigenen Zeile.
await expect(page.getByText('Einzelne größere Zahlungen')).toBeVisible()
const zeile = page.locator('tr', { hasText: EINZELNE })
await expect(zeile).toContainText('480,00')
// Ohne Vertrag - genau deshalb steht sie hier.
await expect(zeile).toContainText('keiner')
})
test('übernimmt Gesellschaft und Beitrag ins Vertragsformular', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
const zeile = page.locator('tr', { hasText: 'Prüfhausen Lebensversicherung' }).first()
await zeile.getByRole('link', { name: 'Vertrag anlegen' }).click()
await page.waitForURL('**/vertraege/neu**')
await expect(page.getByText('aus den wiederkehrenden Zahlungen')).toBeVisible()
// Was aus dem Auszug ablesbar ist, steht drin.
await expect(page.locator('input[name="gesellschaft"]')).toHaveValue(
/Prüfhausen Lebensversicherung/,
)
await expect(page.locator('input[name="beitrag"]')).toHaveValue('42,50')
// Was NICHT ablesbar ist, bleibt leer - eine geratene Vorbelegung
// würde übersehen statt korrigiert.
await expect(page.locator('input[name="bezeichnung"]')).toHaveValue('')
})
test('hakt eine Gruppe ab und holt sie wieder zurück', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
const zeile = page.locator('tr', { hasText: 'Prüfhausen Lebensversicherung' }).first()
await zeile.getByRole('button', { name: 'Kein Vertrag' }).click()
await zeile.getByPlaceholder('Warum kein Vertrag?').fill('Nur zum Prüfen')
await zeile.getByRole('button', { name: 'Abhaken', exact: true }).click()
// Aus der Kandidatenliste verschwunden, unter „abgehakt" wieder da.
await expect(page.getByRole('cell', { name: /Prüfhausen Lebensversicherung/ })).toHaveCount(0)
await expect(page.getByText('Nur zum Prüfen')).toBeVisible()
// Und die Entscheidung überlebt das Neuladen.
await page.reload()
await expect(page.getByText('Nur zum Prüfen')).toBeVisible()
await page.getByRole('button', { name: 'Zurückholen' }).first().click()
await expect(page.getByRole('cell', { name: /Prüfhausen Lebensversicherung/ })).toHaveCount(1)
})
})
+298 -2
View File
@@ -1,12 +1,12 @@
{
"name": "betreuungs-cockpit",
"version": "0.1.0",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "betreuungs-cockpit",
"version": "0.1.0",
"version": "1.0.5",
"hasInstallScript": true,
"dependencies": {
"@aws-sdk/client-s3": "3.735.0",
@@ -18,6 +18,8 @@
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-slot": "1.1.1",
"@react-pdf/renderer": "4.1.6",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.2",
"archiver": "7.0.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -1573,6 +1575,12 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2161,6 +2169,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -2706,6 +2720,207 @@
"pako": "^1.0.10"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz",
"integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz",
"integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"@peculiar/asn1-x509-attr": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz",
"integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz",
"integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz",
"integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.9.4",
"@peculiar/asn1-pkcs8": "^2.9.4",
"@peculiar/asn1-rsa": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz",
"integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz",
"integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.9.4",
"@peculiar/asn1-pfx": "^2.9.4",
"@peculiar/asn1-pkcs8": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"@peculiar/asn1-x509-attr": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz",
"integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz",
"integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz",
"integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz",
"integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -4033,6 +4248,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/x509": "^1.14.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@smithy/config-resolver": {
"version": "4.7.2",
"resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.7.2.tgz",
@@ -5855,6 +6095,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -10192,6 +10446,24 @@
"node": ">=6"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -10398,6 +10670,12 @@
"node": ">=8.10.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -11687,6 +11965,24 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+9 -1
View File
@@ -1,6 +1,6 @@
{
"name": "betreuungs-cockpit",
"version": "1.0.5",
"version": "1.5.0",
"private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": {
@@ -44,6 +44,8 @@
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-slot": "1.1.1",
"@react-pdf/renderer": "4.1.6",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.2",
"archiver": "7.0.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -90,5 +92,11 @@
"prisma@6.3.1": true,
"sharp@0.33.5": true,
"unrs-resolver@1.12.2": true
},
"overrides": {
"next-auth": {
"@simplewebauthn/server": "$@simplewebauthn/server",
"@simplewebauthn/browser": "$@simplewebauthn/browser"
}
}
}
@@ -0,0 +1,67 @@
-- Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg.
--
-- ACHTUNG beim Erzeugen weiterer Migrationen: "prisma migrate diff" wollte
-- hier zusaetzlich die Trigramm-Indizes "dokument_absender_trgm" und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und
-- sind Prisma unbekannt - ein DROP haette die unscharfe Suche stillgelegt,
-- ohne dass irgendetwas fehlgeschlagen waere. Solche Zeilen gehoeren aus
-- jeder erzeugten Migration entfernt.
-- CreateEnum
CREATE TYPE "PasskeyAnfrageArt" AS ENUM ('REGISTRIERUNG', 'ANMELDUNG');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AuditAktion" ADD VALUE 'PASSKEY_REGISTRIERT';
ALTER TYPE "AuditAktion" ADD VALUE 'PASSKEY_ENTFERNT';
-- CreateTable
CREATE TABLE "passkey" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"credentialId" TEXT NOT NULL,
"publicKey" BYTEA NOT NULL,
"counter" BIGINT NOT NULL DEFAULT 0,
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
"name" TEXT NOT NULL,
"geraeteArt" TEXT,
"gesichert" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztBenutztAm" TIMESTAMP(3),
CONSTRAINT "passkey_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "passkey_anfrage" (
"id" TEXT NOT NULL,
"challenge" TEXT NOT NULL,
"art" "PasskeyAnfrageArt" NOT NULL,
"userId" TEXT,
"ablaufAm" TIMESTAMP(3) NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "passkey_anfrage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "passkey_credentialId_key" ON "passkey"("credentialId");
-- CreateIndex
CREATE INDEX "passkey_userId_idx" ON "passkey"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "passkey_anfrage_challenge_key" ON "passkey_anfrage"("challenge");
-- CreateIndex
CREATE INDEX "passkey_anfrage_ablaufAm_idx" ON "passkey_anfrage"("ablaufAm");
-- AddForeignKey
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,30 @@
-- Abgehakte Zahlungsgruppen: "das ist kein Vertrag".
--
-- ACHTUNG, zum zweiten Mal in diesem Projekt: "prisma migrate diff" wollte
-- hier wieder die Trigramm-Indizes "dokument_absender_trgm" und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und
-- sind Prisma unbekannt; ein DROP haette die unscharfe Suche stillgelegt,
-- ohne dass irgendetwas fehlgeschlagen waere. Die Zeilen sind entfernt.
-- Wer die naechste Migration erzeugt: nachsehen und wieder herausnehmen.
-- CreateTable
CREATE TABLE "zahlungsgruppe_geklaert" (
"id" TEXT NOT NULL,
"betreuteId" TEXT NOT NULL,
"schluessel" TEXT NOT NULL,
"anzeigename" TEXT NOT NULL,
"begruendung" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "zahlungsgruppe_geklaert_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "zahlungsgruppe_geklaert_betreuteId_idx" ON "zahlungsgruppe_geklaert"("betreuteId");
-- CreateIndex
CREATE UNIQUE INDEX "zahlungsgruppe_geklaert_betreuteId_schluessel_key" ON "zahlungsgruppe_geklaert"("betreuteId", "schluessel");
-- AddForeignKey
ALTER TABLE "zahlungsgruppe_geklaert" ADD CONSTRAINT "zahlungsgruppe_geklaert_betreuteId_fkey" FOREIGN KEY ("betreuteId") REFERENCES "betreute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- Dauerzahlungen: Buchung.vertragId.
--
-- ACHTUNG, zum dritten Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes "dokument_absender_trgm" und "vorgang_titel_trgm"
-- loeschen. Sie stammen aus prisma/sql/suche.sql und sind Prisma
-- unbekannt; ein DROP legt die unscharfe Suche stumm, ohne dass etwas
-- fehlschlaegt. Zeilen entfernt. Wer die naechste Migration erzeugt:
-- nachsehen.
-- AlterTable
ALTER TABLE "buchung" ADD COLUMN "vertragAutomatisch" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "vertragId" TEXT;
-- CreateIndex
CREATE INDEX "buchung_vertragId_idx" ON "buchung"("vertragId");
-- AddForeignKey
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_vertragId_fkey" FOREIGN KEY ("vertragId") REFERENCES "vertrag"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+197 -55
View File
@@ -89,6 +89,7 @@ model User {
geloeschtAm DateTime?
zugaenge BetreuungsZugang[]
passkeys Passkey[]
auditEintraege AuditLog[]
angelegteVorgaenge Vorgang[] @relation("VorgangAngelegtVon")
berichte Bericht[]
@@ -135,6 +136,91 @@ model LoginVersuch {
@@map("login_versuch")
}
/// Ein registrierter Passkey (WebAuthn/FIDO2).
///
/// Der private Schluessel verlaesst das Geraet nie - hier liegt nur der
/// oeffentliche Teil. Ein gestohlener Datenbankabzug erlaubt deshalb keine
/// Anmeldung, anders als bei einem Passworthash, den man offline angreifen
/// kann.
///
/// Ein Passkey zaehlt bei uns als ZWEI Faktoren: Besitz des Geraets plus
/// die Nutzerverifikation darauf (Fingerabdruck, Gesicht oder Geraete-PIN).
/// Deshalb wird bei der Anmeldung "userVerified" erzwungen - siehe
/// src/lib/auth/passkey.ts. Ohne diese Pruefung waere es nur ein Faktor.
model Passkey {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Kennung des Authenticators, base64url. Eindeutig ueber alle Benutzer -
/// derselbe Schluessel darf nicht an zwei Zugaengen haengen.
credentialId String @unique
/// Oeffentlicher Schluessel im COSE-Format. Kein Geheimnis.
publicKey Bytes
/// Signaturzaehler des Authenticators. Zaehlt er nicht hoch, obwohl er
/// es vorher tat, ist der Schluessel womoeglich geklont worden.
/// BigInt, weil der Zaehler bis 2^32-1 geht.
counter BigInt @default(0)
/// "usb", "nfc", "ble", "internal", "hybrid" - hilft dem Browser beim
/// naechsten Mal, das richtige Geraet vorzuschlagen.
transports String[] @default([])
/// Vom Benutzer vergeben ("iPhone von Florian"). Bei mehreren Geraeten
/// ist das die einzige Moeglichkeit, sie auseinanderzuhalten.
name String
/// "singleDevice" = liegt nur auf diesem Geraet, "multiDevice" = wird vom
/// Schluesselbund synchronisiert. Steht in der Uebersicht, weil es den
/// Unterschied macht, ob ein verlorenes Geraet den Zugang mitnimmt.
geraeteArt String?
/// Ob der Anbieter den Schluessel gesichert hat (iCloud, Google, Bitwarden).
gesichert Boolean @default(false)
erstelltAm DateTime @default(now())
zuletztBenutztAm DateTime?
@@index([userId])
@@map("passkey")
}
enum PasskeyAnfrageArt {
REGISTRIERUNG
ANMELDUNG
}
/// Kurzlebiger Speicher fuer die Challenge einer laufenden Passkey-Aktion.
///
/// Die Challenge muss serverseitig liegen und genau einmal gelten, sonst
/// liesse sich eine mitgeschnittene Antwort erneut einspielen. Verbraucht
/// wird sie durch DELETE - dass genau eine Zeile geloescht wurde, ist der
/// Beweis, dass sie noch nicht benutzt war.
///
/// Bewusst ohne Fremdschluessel auf User: die Zeile lebt fuenf Minuten und
/// ist kein Fachdatum. Der Wartungslauf raeumt Liegengebliebenes weg.
model PasskeyAnfrage {
id String @id @default(cuid())
/// base64url, zufaellig. Eindeutig, damit das Verbrauchen atomar ist.
challenge String @unique
art PasskeyAnfrageArt
/// Bei der Registrierung bekannt. Bei der Anmeldung nicht - dort weiss
/// der Server erst nach der Antwort, wer sich anmeldet.
userId String?
ablaufAm DateTime
erstelltAm DateTime @default(now())
@@index([ablaufAm])
@@map("passkey_anfrage")
}
// =============================================================================
// Betreute Person
// =============================================================================
@@ -191,8 +277,8 @@ model Betreute {
/// Seit der Reform 2023 Leitschnur des Betreuerhandelns.
ermittelterWilleVerschluesselt String?
patientenverfuegungVorhanden Boolean @default(false)
vorsorgevollmachtVorhanden Boolean @default(false)
patientenverfuegungVorhanden Boolean @default(false)
vorsorgevollmachtVorhanden Boolean @default(false)
// --- Betreuungsgericht ---------------------------------------------------
betreuungsgericht String?
@@ -226,6 +312,7 @@ model Betreute {
dokumente Dokument[]
konten Konto[]
buchungen Buchung[]
zahlungsgruppen ZahlungsgruppeGeklaert[]
vermoegensposten Vermoegensposten[]
immobilien Immobilie[]
vertraege Vertrag[]
@@ -315,8 +402,8 @@ model Vorgang {
kontaktprotokolle Kontaktprotokoll[]
aufgaben Aufgabe[]
verknuepfungen DokumentVerknuepfung[]
tags Tag[] @relation("VorgangTags")
kontakte Kontakt[] @relation("VorgangKontakte")
tags Tag[] @relation("VorgangTags")
kontakte Kontakt[] @relation("VorgangKontakte")
@@index([betreuteId, status])
@@index([betreuteId, kategorie])
@@ -742,6 +829,42 @@ enum BuchungKategorie {
UMBUCHUNG
}
/// Eine Gruppe wiederkehrender Zahlungen, zu der bewusst KEIN Vertrag
/// gefuehrt wird - Miete, Strom, Rundfunkbeitrag, Finanzamt.
///
/// Die Entscheidung haengt am normalisierten Namen der Gegenstelle, nicht
/// an den Buchungen. Nur so ueberlebt sie den naechsten Kontoauszug: Beim
/// Import entstehen neue Buchungszeilen, der Empfaenger bleibt derselbe.
///
/// ACHTUNG: Der Schluessel wird von normalisieren() in
/// src/lib/finanzen/wiederkehrend.ts gebildet. Aendert sich diese Funktion,
/// passen bestehende Schluessel nicht mehr und abgehakte Gruppen tauchen
/// wieder auf. Das ist unangenehm, aber ungefaehrlich - schlimmer waere,
/// eine Gruppe stillschweigend weiter zu verbergen, die inzwischen anders
/// zugeschnitten ist.
model ZahlungsgruppeGeklaert {
id String @id @default(cuid())
betreuteId String
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
/// Normalisierter Name der Gegenstelle.
schluessel String
/// Die Schreibweise, wie sie beim Abhaken auf dem Bildschirm stand -
/// damit spaeter noch erkennbar ist, worum es ging.
anzeigename String
/// Warum kein Vertrag? Freiwillig, aber in einem Jahr Gold wert.
begruendung String?
erstelltAm DateTime @default(now())
@@unique([betreuteId, schluessel])
@@index([betreuteId])
@@map("zahlungsgruppe_geklaert")
}
model Buchung {
id String @id @default(cuid())
@@ -754,21 +877,36 @@ model Buchung {
vorgangId String?
vorgang Vorgang? @relation(fields: [vorgangId], references: [id], onDelete: SetNull)
datum DateTime @db.Date
wertstellung DateTime? @db.Date
betragCent BigInt
richtung BuchungRichtung
kategorie BuchungKategorie @default(SONSTIGE_AUSGABE)
gegenstelle String?
datum DateTime @db.Date
wertstellung DateTime? @db.Date
betragCent BigInt
richtung BuchungRichtung
kategorie BuchungKategorie @default(SONSTIGE_AUSGABE)
gegenstelle String?
verwendungszweckVerschluesselt String?
belegId String?
beleg Dokument? @relation("Buchungsbeleg", fields: [belegId], references: [id], onDelete: SetNull)
belegId String?
beleg Dokument? @relation("Buchungsbeleg", fields: [belegId], references: [id], onDelete: SetNull)
/// Buchung ohne Beleg wird in der Liste markiert - Belegzwang.
belegFehltBegruendung String?
/// Dauerzahlung zu einem Vertrag. Bei einem Dauerschuldverhaeltnis IST
/// der Vertrag der Beleg - fuer zwoelf gleiche Lastschriften gibt es
/// keine zwoelf Quittungen, sondern eine Police.
///
/// onDelete: SetNull ist Absicht und Teil der Zusage: Verschwindet der
/// Vertrag, faellt die Buchung zurueck in "Beleg fehlt". Ein Nachweis,
/// der auf etwas zeigt, das es nicht mehr gibt, waere schlimmer als
/// gar keiner.
vertragId String?
vertrag Vertrag? @relation("Vertragszahlung", fields: [vertragId], references: [id], onDelete: SetNull)
/// Wurde die Zuordnung beim Import automatisch gesetzt? Dann ist sie
/// eine Vermutung der Maschine und in der Liste als solche erkennbar.
vertragAutomatisch Boolean @default(false)
/// false = Auslage des Betreuers, die erstattet werden muss.
betrifftBetreute Boolean @default(true)
betrifftBetreute Boolean @default(true)
/// Verschiebt das Vermoegen (z.B. Verkaufserloes), nicht nur laufende
/// Einnahme/Ausgabe. Relevant fuer die Vermoegensuebersicht.
vermoegenswirksam Boolean @default(false)
@@ -785,6 +923,7 @@ model Buchung {
@@unique([kontoId, importReferenz])
@@index([betreuteId, datum])
@@index([betreuteId, kategorie])
@@index([vertragId])
@@map("buchung")
}
@@ -827,7 +966,7 @@ model Vermoegensposten {
/// Passiva werden als eigener Posten mit Art VERBINDLICHKEIT gefuehrt;
/// der Wert bleibt positiv, das Vorzeichen ergibt sich aus der Art.
bewertungSozialhilfe SozialhilfeBewertung @default(UNGEPRUEFT)
bewertungSozialhilfe SozialhilfeBewertung @default(UNGEPRUEFT)
/// Warum halte ich den Posten fuer geschuetzt? Verweis auf § 90 SGB XII,
/// Schonvermoegen, Bestattungsvorsorge, selbst bewohntes Hausgrundstueck.
bewertungBegruendungVerschluesselt String?
@@ -852,8 +991,8 @@ model Wertstand {
vermoegenspostenId String
vermoegensposten Vermoegensposten @relation(fields: [vermoegenspostenId], references: [id], onDelete: Cascade)
stichtag DateTime @db.Date
wertCent BigInt
stichtag DateTime @db.Date
wertCent BigInt
/// Woher stammt der Wert? "Rueckkaufswertmitteilung vom 12.03.2026",
/// "Maklergutachten", "Bodenrichtwert", "eigene Schaetzung".
wertquelle String
@@ -905,7 +1044,7 @@ model Immobilie {
bewohntStatus String? // "leerstehend", "vermietet", "selbst bewohnt"
leerstandSeit DateTime? @db.Date
statusSozialhilfe ImmobilienStatusSozialhilfe @default(UNGEKLAERT)
statusSozialhilfe ImmobilienStatusSozialhilfe @default(UNGEKLAERT)
statusBegruendungVerschluesselt String?
notizenVerschluesselt String?
@@ -951,14 +1090,14 @@ model ImmobilienCheckpunkt {
immobilieId String
immobilie Immobilie @relation(fields: [immobilieId], references: [id], onDelete: Cascade)
titel String
beschreibung String?
erledigt Boolean @default(false)
erledigtAm DateTime? @db.Date
faelligAm DateTime? @db.Date
titel String
beschreibung String?
erledigt Boolean @default(false)
erledigtAm DateTime? @db.Date
faelligAm DateTime? @db.Date
/// Wiederkehrend (z.B. Kontrollgang, Zaehlerstand) als RRULE.
wiederholungRrule String?
reihenfolge Int @default(0)
reihenfolge Int @default(0)
erstelltAm DateTime @default(now())
@@ -1024,8 +1163,8 @@ model Vertrag {
vertragsnummerVerschluesselt String?
beginnAm DateTime? @db.Date
endeAm DateTime? @db.Date
beginnAm DateTime? @db.Date
endeAm DateTime? @db.Date
/// Kuendigungsfrist in Monaten zum Ende der Laufzeit.
kuendigungsfristMonate Int?
kuendigungZum DateTime? @db.Date
@@ -1050,6 +1189,7 @@ model Vertrag {
vermoegensposten Vermoegensposten[]
verknuepfungen DokumentVerknuepfung[]
zahlungen Buchung[] @relation("Vertragszahlung")
@@index([betreuteId, status])
@@map("vertrag")
@@ -1095,13 +1235,13 @@ model Kontakt {
organisation String?
rolle KontaktRolle @default(SONSTIGES)
telefon String?
mobil String?
fax String?
email String?
strasse String?
plz String?
ort String?
telefon String?
mobil String?
fax String?
email String?
strasse String?
plz String?
ort String?
/// Aktenzeichen, unter dem dieser Kontakt den Vorgang fuehrt.
aktenzeichen String?
@@ -1112,7 +1252,7 @@ model Kontakt {
geloeschtAm DateTime?
protokolle Kontaktprotokoll[]
vorgaenge Vorgang[] @relation("VorgangKontakte")
vorgaenge Vorgang[] @relation("VorgangKontakte")
verknuepfungen DokumentVerknuepfung[]
@@index([betreuteId, rolle])
@@ -1149,7 +1289,7 @@ model Kontaktprotokoll {
/// true = persoenlicher Kontakt zur betreuten Person selbst.
mitBetreuter Boolean @default(false)
inhaltVerschluesselt String?
inhaltVerschluesselt String?
/// Geaeusserte Wuensche - fuer § 1821 BGB und den Jahresbericht.
wuenscheVerschluesselt String?
folgeaufgabenVerschluesselt String?
@@ -1224,10 +1364,10 @@ model Heimvertrag {
plz String?
ort String?
vertragAm DateTime? @db.Date
einzugAm DateTime? @db.Date
auszugAm DateTime? @db.Date
kuendigungsfristTage Int? @default(14)
vertragAm DateTime? @db.Date
einzugAm DateTime? @db.Date
auszugAm DateTime? @db.Date
kuendigungsfristTage Int? @default(14)
// Aktuelle Saetze - historische Werte stehen in Heimkostenmonat.
pflegeEigenanteilCent BigInt? // einrichtungseinheitlicher Eigenanteil
@@ -1261,7 +1401,7 @@ model Heimkostenmonat {
zusatzleistungenCent BigInt @default(0)
// Deckungsseite
pflegekasseCent BigInt @default(0) // § 43 SGB XI
pflegekasseCent BigInt @default(0) // § 43 SGB XI
/// Leistungszuschlag nach § 43c SGB XI, abhaengig von der Verweildauer.
leistungszuschlagProzent Int @default(0)
leistungszuschlagCent BigInt @default(0)
@@ -1360,14 +1500,14 @@ model Nachforderung {
erlaeuterung String?
status NachforderungStatus @default(OFFEN)
angefordertAm DateTime? @db.Date
fristBis DateTime? @db.Date
uebersandtAm DateTime? @db.Date
angefordertAm DateTime? @db.Date
fristBis DateTime? @db.Date
uebersandtAm DateTime? @db.Date
/// Wenn die Unterlage nicht existiert: warum nicht? Geht als Erklaerung
/// mit ins Unterlagenpaket.
begruendungNichtVorhanden String?
reihenfolge Int @default(0)
reihenfolge Int @default(0)
/// Vorbefuellte Standardunterlage aus der Checkliste.
ausCheckliste Boolean @default(false)
@@ -1387,12 +1527,12 @@ model Bescheid {
antragId String
antrag SozialhilfeAntrag @relation(fields: [antragId], references: [id], onDelete: Cascade)
art BescheidArt
bescheidVom DateTime @db.Date
art BescheidArt
bescheidVom DateTime @db.Date
/// Massgeblich fuer den Fristbeginn - nicht das Datum auf dem Bescheid.
zugegangenAm DateTime? @db.Date
zeitraumVon DateTime? @db.Date
zeitraumBis DateTime? @db.Date
zugegangenAm DateTime? @db.Date
zeitraumVon DateTime? @db.Date
zeitraumBis DateTime? @db.Date
betragCent BigInt?
/// In der Regel ein Monat ab Bekanntgabe (§ 84 SGG). Bei fehlender oder
@@ -1482,11 +1622,11 @@ model Bericht {
betreuteId String
betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade)
art BerichtArt
titel String
zeitraumVon DateTime? @db.Date
zeitraumBis DateTime? @db.Date
stichtag DateTime? @db.Date
art BerichtArt
titel String
zeitraumVon DateTime? @db.Date
zeitraumBis DateTime? @db.Date
stichtag DateTime? @db.Date
/// Vom Nutzer ueberarbeiteter Freitext (Jahresbericht) - verschluesselt.
entwurfVerschluesselt String?
@@ -1527,6 +1667,8 @@ enum AuditAktion {
ANMELDUNG_FEHLGESCHLAGEN
ABMELDUNG
ZWEITER_FAKTOR_EINGERICHTET
PASSKEY_REGISTRIERT
PASSKEY_ENTFERNT
PASSWORT_GEAENDERT
ROLLE_GEAENDERT
FREIGABE_GEAENDERT
@@ -1539,8 +1681,8 @@ model AuditLog {
id String @id @default(cuid())
zeitpunkt DateTime @default(now())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
/// Momentaufnahme - bleibt lesbar, auch wenn der Benutzer geloescht wird.
userEmail String?
userName String?
Regular → Executable
View File
Regular → Executable
+7
View File
@@ -288,6 +288,13 @@ fi
gut "$(git describe --tags --always)"
# Das Ausfuehrbarkeitsbit kommt normalerweise aus dem Repository mit. Wer
# Dateien von Hand kopiert hat - etwa um an dieses Skript zu kommen, bevor
# es in der laufenden Fassung existierte -, hat es verloren. Dann waere der
# naechste Aufruf ein "Permission denied", und man sucht an der falschen
# Stelle.
chmod +x scripts/*.sh 2>/dev/null || true
schritt "Bauen und starten"
printf ' Das dauert beim ersten Mal einige Minuten.\n\n'
+6 -1
View File
@@ -97,7 +97,12 @@ schreiben('src/lib/version.ts', ersetzt)
// --- 3. CHANGELOG.md --------------------------------------------------------
const heute = new Date().toISOString().slice(0, 10)
// Deutsche Zeit, nicht UTC. Sonst bekommt eine Veroeffentlichung nach
// 22 Uhr (Sommerzeit) das Datum des Vortages - dieselbe Zeitzonenfalle,
// derentwegen die Fristen im Schema @db.Date sind.
const heute = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Berlin',
}).format(new Date())
let changelog = lesen('CHANGELOG.md')
const UNVEROEFFENTLICHT = '## [Unveröffentlicht]'
Regular → Executable
View File
+128 -24
View File
@@ -1,9 +1,16 @@
'use client'
import { useActionState } from 'react'
import { useActionState, useEffect, useState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { AlertCircle, Fingerprint, Loader2, ShieldCheck } from 'lucide-react'
import { anmeldungBestaetigen, type BestaetigenZustand } from '@/server/actions/bestaetigen'
import { passkeyBestaetigen, passkeyBestaetigungStarten } from '@/server/actions/passkey'
import {
browserSupportsWebAuthn,
passkeyBrowserfehler,
startAuthentication,
} from '@/lib/passkey-browser'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -19,36 +26,133 @@ function Absenden() {
)
}
export function BestaetigenFormular({ weiter }: { weiter: string }) {
export function BestaetigenFormular({
weiter,
passkeysMoeglich,
}: {
weiter: string
passkeysMoeglich: boolean
}) {
const [zustand, aktion] = useActionState<BestaetigenZustand, FormData>(anmeldungBestaetigen, {})
return (
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="weiter" value={weiter} />
<div className="space-y-5">
<form action={aktion} className="space-y-4" noValidate>
<input type="hidden" name="weiter" value={weiter} />
{zustand.fehler && (
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="passwort" beschriftung="Passwort" pflicht>
<Input name="passwort" type="password" autoComplete="current-password" required autoFocus />
</Feld>
<Feld id="code" beschriftung="Code aus der Authenticator-App" pflicht>
<Input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
className="text-center font-mono text-lg tracking-[0.4em]"
required
/>
</Feld>
<Absenden />
</form>
{passkeysMoeglich && <PasskeyBestaetigung weiter={weiter} />}
</div>
)
}
/**
* Bestaetigung mit Passkey statt mit Passwort und Code.
*
* Geprueft wird zusaetzlich, dass der Passkey zum angemeldeten Benutzer
* gehoert - sonst koennte jemand mit dem eigenen Finger eine fremde,
* offen stehende Sitzung freischalten. Genau der Fall, gegen den diese
* Seite gebaut ist.
*/
function PasskeyBestaetigung({ weiter }: { weiter: string }) {
const router = useRouter()
const [browserKann, setBrowserKann] = useState(false)
const [laeuft, setLaeuft] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
useEffect(() => {
setBrowserKann(browserSupportsWebAuthn())
}, [])
async function bestaetigen() {
setFehler(null)
setLaeuft(true)
try {
const start = await passkeyBestaetigungStarten()
if (!start.ok) {
setFehler(start.fehler)
return
}
const antwort = await startAuthentication({ optionsJSON: start.optionen })
const ergebnis = await passkeyBestaetigen(JSON.stringify(antwort))
if (!ergebnis.ok) {
setFehler(ergebnis.fehler)
return
}
router.replace(weiter)
router.refresh()
} catch (ausnahme) {
setFehler(passkeyBrowserfehler(ausnahme))
} finally {
setLaeuft(false)
}
}
if (!browserKann) return null
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-muted-foreground">oder</span>
<span className="h-px flex-1 bg-border" />
</div>
{fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
<Feld id="passwort" beschriftung="Passwort" pflicht>
<Input name="passwort" type="password" autoComplete="current-password" required autoFocus />
</Feld>
<Feld id="code" beschriftung="Code aus der Authenticator-App" pflicht>
<Input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
className="text-center font-mono text-lg tracking-[0.4em]"
required
/>
</Feld>
<Absenden />
</form>
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={() => void bestaetigen()}
disabled={laeuft}
>
{laeuft ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Warte auf das Gerät …
</>
) : (
<>
<Fingerprint aria-hidden="true" />
Mit Passkey bestätigen
</>
)}
</Button>
</div>
)
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { ShieldAlert } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { erfordereBenutzer } from '@/lib/auth/sitzung'
import { passkeysMoeglich } from '@/lib/auth/passkey'
import { BestaetigenFormular } from './formular'
export const metadata: Metadata = { title: 'Bestätigen' }
@@ -26,7 +27,7 @@ export default async function BestaetigenSeite({
</CardDescription>
</CardHeader>
<CardContent>
<BestaetigenFormular weiter={weiter} />
<BestaetigenFormular weiter={weiter} passkeysMoeglich={passkeysMoeglich()} />
</CardContent>
</Card>
@@ -26,7 +26,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
const offen = oeffnen('Buchung', buchung)
const [konten, belege] = await Promise.all([
const [konten, belege, vertraege] = await Promise.all([
db.konto.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, istBetreuerkonto: true },
@@ -38,6 +38,11 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
orderBy: { erstelltAm: 'desc' },
take: 200,
}),
db.vertrag.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
orderBy: { bezeichnung: 'asc' },
}),
])
return (
@@ -64,6 +69,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
betreuteId={betreute.id}
konten={konten}
belege={belege}
vertraege={vertraege}
buchung={{
id: buchung.id,
kontoId: buchung.kontoId,
@@ -75,6 +81,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s
gegenstelle: buchung.gegenstelle,
verwendungszweck: offen.verwendungszweck,
belegId: buchung.belegId,
vertragId: buchung.vertragId,
belegFehltBegruendung: buchung.belegFehltBegruendung,
betrifftBetreute: buchung.betrifftBetreute,
vermoegenswirksam: buchung.vermoegenswirksam,
@@ -30,7 +30,7 @@ export default async function BuchungNeuSeite({
const schnell = parameter.schnell === '1'
const kontoId = typeof parameter.kontoId === 'string' ? parameter.kontoId : null
const [konten, belege] = await Promise.all([
const [konten, belege, vertraege] = await Promise.all([
db.konto.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, istBetreuerkonto: true },
@@ -42,6 +42,11 @@ export default async function BuchungNeuSeite({
orderBy: { erstelltAm: 'desc' },
take: 200,
}),
db.vertrag.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
orderBy: { bezeichnung: 'asc' },
}),
])
return (
@@ -93,6 +98,7 @@ export default async function BuchungNeuSeite({
: konten
}
belege={belege}
vertraege={vertraege}
schnellerfassung={schnell}
standardDatum={tagAlsText(heute())}
/>
+9 -26
View File
@@ -9,10 +9,10 @@ import { Rechtshinweis } from '@/components/rechtshinweis'
import { Belegzeichen, Betrag, Kennzahl, Vermischungswarnung } from '@/components/finanzen/bausteine'
import { Buchungsfilter } from '@/components/finanzen/buchungs-filter'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { belegFehlt, kennzahlen, vermischungsWarnung, type BuchungEingabe } from '@/lib/finanzen/kassenbuch'
import { BUCHUNG_EINBEZUG, zuBuchungEingabe } from '@/lib/berichte/gemeinsam'
import { belegDurchVertrag, betragPruefen, belegFehlt, kennzahlen, vermischungsWarnung, type BuchungEingabe } from '@/lib/finanzen/kassenbuch'
import { KATEGORIE_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { buchungsfilterSchema } from '@/lib/validierung/finanzen'
@@ -82,10 +82,7 @@ export default async function BuchungenSeite({
const [zeilen, gesamt, konten] = await Promise.all([
db.buchung.findMany({
where: bedingung,
include: {
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
beleg: { select: { id: true, titel: true } },
},
include: BUCHUNG_EINBEZUG,
orderBy: [{ datum: 'desc' }, { erstelltAm: 'desc' }],
take: HOECHSTZAHL,
}),
@@ -97,26 +94,7 @@ export default async function BuchungenSeite({
}),
])
const buchungen: BuchungEingabe[] = zeilen.map((zeile) => {
const offen = oeffnen('Buchung', zeile)
return {
id: zeile.id,
datum: zeile.datum,
betragCent: zeile.betragCent,
richtung: zeile.richtung,
kategorie: zeile.kategorie,
gegenstelle: zeile.gegenstelle,
verwendungszweck: offen.verwendungszweck,
kontoId: zeile.kontoId,
kontoBezeichnung: zeile.konto?.bezeichnung ?? null,
belegId: zeile.belegId,
belegTitel: zeile.beleg?.titel ?? null,
belegFehltBegruendung: zeile.belegFehltBegruendung,
betrifftBetreute: zeile.betrifftBetreute,
vermoegenswirksam: zeile.vermoegenswirksam,
aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false,
}
})
const buchungen: BuchungEingabe[] = zeilen.map(zuBuchungEingabe)
const zahlen = kennzahlen(buchungen)
@@ -261,6 +239,11 @@ export default async function BuchungenSeite({
belegId={buchung.belegId}
belegTitel={buchung.belegTitel ?? null}
begruendung={buchung.belegFehltBegruendung}
vertragBezeichnung={
belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null
}
vertragAutomatisch={buchung.vertragAutomatisch ?? false}
betragZuPruefen={betragPruefen(buchung)}
/>
</TableCell>
<TableCell className="text-right align-top">
+33 -5
View File
@@ -1,5 +1,6 @@
import Link from 'next/link'
import { ArrowRight, Banknote, FileDown, Landmark, Plus, Upload } from 'lucide-react'
import { ArrowRight, Banknote, FileDown, Landmark, Plus, Repeat, Upload } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
@@ -16,10 +17,11 @@ import { centZuZahl } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils'
import { finanzKontext } from '@/lib/finanzen/seite'
import { heute, monatePlus, tagePlus } from '@/lib/finanzen/datum'
import { kennzahlen, vermischungsWarnung } from '@/lib/finanzen/kassenbuch'
import { belegDurchVertrag, betragPruefen, kennzahlen, vermischungsWarnung } from '@/lib/finanzen/kassenbuch'
import { KATEGORIE_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { laufendesBetreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import { buchungenLaden, kontenLaden, kontostaendeZumStichtag, vermoegenLaden } from '@/lib/berichte/gemeinsam'
import { offeneZahlungsgruppen } from '@/lib/finanzen/wiederkehrend-laden'
export const metadata = { title: 'Vermögen & Kasse' }
@@ -48,6 +50,7 @@ export default async function FinanzenSeite() {
const zwoelfMonate = tagePlus(monatePlus(stichtag, -12), 1)
const letzteZwoelf = buchungen.filter((b) => b.datum >= zwoelfMonate)
const zahlen = kennzahlen(letzteZwoelf)
const offeneGruppen = await offeneZahlungsgruppen(betreute.id)
const letzte = [...buchungen].sort((a, b) => b.datum.getTime() - a.datum.getTime()).slice(0, 8)
@@ -118,9 +121,13 @@ export default async function FinanzenSeite() {
titel="Belegquote (12 Monate)"
wert={`${zahlen.beleg.quoteProzent} %`}
hinweis={
zahlen.beleg.ohneNachweis > 0
? `${zahlen.beleg.ohneNachweis} Buchung(en) ohne Beleg und ohne Begründung`
: 'Alle Buchungen sind belegt oder begründet'
zahlen.beleg.zuPruefen > 0
? `${zahlen.beleg.zuPruefen} Zahlung(en) weichen vom hinterlegten Beitrag ab`
: zahlen.beleg.ohneNachweis > 0
? `${zahlen.beleg.ohneNachweis} Buchung(en) ohne Beleg und ohne Begründung`
: zahlen.beleg.durchVertrag > 0
? `Belegt, begründet oder durch Vertrag gedeckt (${zahlen.beleg.durchVertrag} Dauerzahlungen)`
: 'Alle Buchungen sind belegt oder begründet'
}
ton={zahlen.beleg.ohneNachweis > 0 ? 'achtung' : 'gut'}
href="/finanzen/buchungen?nurOhneBeleg=on"
@@ -134,6 +141,22 @@ export default async function FinanzenSeite() {
/>
</div>
{offeneGruppen > 0 && (
<Alert variant="hinweis">
<Repeat aria-hidden="true" />
<AlertDescription className="flex flex-wrap items-center justify-between gap-3">
<span>
<strong>{offeneGruppen}</strong> regelmäßige Abbuchung
{offeneGruppen === 1 ? '' : 'en'} ohne hinterlegten Vertrag. Wer regelmäßig
abbucht, hat einen — und der fehlt dann im Vermögensverzeichnis.
</span>
<Button asChild size="sm" variant="outline">
<Link href="/finanzen/wiederkehrend">Ansehen</Link>
</Button>
</AlertDescription>
</Alert>
)}
{zahlen.vermischungen > 0 && <Vermischungswarnung />}
<Hinweisliste hinweise={hinweise} titel="Das solltest du nachtragen" />
@@ -299,6 +322,11 @@ export default async function FinanzenSeite() {
belegId={buchung.belegId}
belegTitel={buchung.belegTitel ?? null}
begruendung={buchung.belegFehltBegruendung}
vertragBezeichnung={
belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null
}
vertragAutomatisch={buchung.vertragAutomatisch ?? false}
betragZuPruefen={betragPruefen(buchung)}
/>
</TableCell>
<TableCell className="text-right">
@@ -0,0 +1,337 @@
import Link from 'next/link'
import { AlertCircle, FileSearch, Plus, Repeat } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Betrag } from '@/components/finanzen/bausteine'
import {
AlsGeklaertAbhaken,
ZahlungenZuordnen,
Zurueckholen,
} from '@/components/finanzen/wiederkehrend-aktionen'
import { finanzKontext } from '@/lib/finanzen/seite'
import { wiederkehrendLaden } from '@/lib/finanzen/wiederkehrend-laden'
import {
EINZELZAHLUNG_AB_CENT,
RHYTHMUS_BESCHRIFTUNG,
rhythmusErklaerung,
} from '@/lib/finanzen/wiederkehrend'
import { centZuZahl } from '@/lib/db'
import { formatCent, formatDatum } from '@/lib/utils'
export const metadata = { title: 'Wiederkehrende Zahlungen' }
export const dynamic = 'force-dynamic'
/**
* Wiederkehrende Zahlungen — die Suche nach unbekannten Verträgen.
*
* Am Anfang einer Betreuung weiß niemand, welche Verträge es gibt. Die
* Antwort steht im Kontoauszug: Wer regelmäßig abbucht, hat einen Vertrag.
* Diese Seite macht aus hunderten Buchungszeilen eine Arbeitsliste.
*
* Sie legt von sich aus nichts an. Jede Zeile endet in einer Entscheidung,
* die ein Mensch trifft — Vertrag anlegen oder abhaken.
*/
export default async function WiederkehrendSeite() {
const { betreute, darfSchreiben } = await finanzKontext()
const schreiben = darfSchreiben('FINANZEN')
const bild = await wiederkehrendLaden(betreute.id)
const ohneVertrag = bild.gruppen.filter((gruppe) => gruppe.vertragId === null)
const mitVertrag = bild.gruppen.filter((gruppe) => gruppe.vertragId !== null)
return (
<div className="space-y-6">
<SeitenKopf
titel="Wiederkehrende Zahlungen"
beschreibung="Wer regelmäßig abbucht, hat einen Vertrag. Hier stehen die Kandidaten, zu denen noch keiner in der Akte liegt."
aktionen={
<Button asChild variant="outline">
<Link href="/finanzen/buchungen">Zu den Buchungen</Link>
</Button>
}
/>
{bild.ausgabenGesamt === 0 ? (
<Leerzustand
icon={FileSearch}
titel="Noch keine Ausgaben erfasst"
beschreibung="Diese Auswertung liest die Kontobewegungen. Importiere zuerst einen Kontoauszug, dann findest du hier die regelmäßigen Abbuchungen."
aktion={
<Button asChild>
<Link href="/finanzen/buchungen/import">Kontoauszug einlesen</Link>
</Button>
}
/>
) : (
<>
{/* --- Kandidaten ohne Vertrag ----------------------------------- */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Repeat className="size-5" aria-hidden="true" />
Ohne hinterlegten Vertrag
{ohneVertrag.length > 0 && (
<Badge variant="secondary">{ohneVertrag.length}</Badge>
)}
</CardTitle>
<CardDescription>
Nach Jahresbetrag sortiert — was am meisten kostet, will man zuerst geklärt haben.
</CardDescription>
</CardHeader>
<CardContent>
{ohneVertrag.length === 0 ? (
<p className="text-sm text-muted-foreground">
Zu jeder regelmäßigen Abbuchung liegt ein Vertrag in der Akte. Das ist der
Zustand, den man haben will.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Empfänger</TableHead>
<TableHead>Rhythmus</TableHead>
<TableHead className="text-right">je Zahlung</TableHead>
<TableHead className="text-right">im Jahr</TableHead>
<TableHead>Zeitraum</TableHead>
{schreiben && <TableHead className="text-right">Und jetzt?</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{ohneVertrag.map((gruppe) => (
<TableRow key={gruppe.schluessel}>
<TableCell className="align-top">
<p className="font-medium">{gruppe.anzeigename}</p>
{gruppe.schreibweisen.length > 1 && (
<p className="mt-0.5 text-xs text-muted-foreground">
{gruppe.schreibweisen.length} Schreibweisen zusammengefasst
</p>
)}
</TableCell>
<TableCell className="align-top">
<span
className={
gruppe.rhythmus === 'UNREGELMAESSIG'
? 'text-muted-foreground'
: undefined
}
>
{RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]}
</span>
<p className="mt-0.5 text-xs text-muted-foreground">
{rhythmusErklaerung(gruppe)}
</p>
</TableCell>
<TableCell className="text-right align-top">
<Betrag cent={centZuZahl(gruppe.betragCent)} />
{gruppe.betragSchwankt && (
<p className="mt-0.5 text-xs text-ampel-orange">
schwankt: {formatCent(centZuZahl(gruppe.kleinsterBetragCent))} bis{' '}
{formatCent(centZuZahl(gruppe.groessterBetragCent))}
</p>
)}
</TableCell>
<TableCell className="text-right align-top">
<Betrag cent={centZuZahl(gruppe.jahresbetragCent)} />
{gruppe.rhythmus === 'UNREGELMAESSIG' && (
<p className="mt-0.5 text-xs text-muted-foreground">
tatsächlich gezahlt
</p>
)}
</TableCell>
<TableCell className="align-top text-sm text-muted-foreground">
{formatDatum(gruppe.ersteZahlung)}
<br />
bis {formatDatum(gruppe.letzteZahlung)}
</TableCell>
{schreiben && (
<TableCell className="align-top">
<div className="flex flex-col items-end gap-2">
<Button asChild size="sm" variant="outline">
<Link
href={`/vertraege/neu?gesellschaft=${encodeURIComponent(
gruppe.anzeigename,
)}&beitragCent=${gruppe.betragCent}&rhythmus=${gruppe.rhythmus}`}
>
<Plus aria-hidden="true" />
Vertrag anlegen
</Link>
</Button>
<AlsGeklaertAbhaken
betreuteId={betreute.id}
schluessel={gruppe.schluessel}
anzeigename={gruppe.anzeigename}
/>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* --- Einzelne groessere Zahlungen ------------------------------ */}
{bild.einzelne.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Einzelne größere Zahlungen</CardTitle>
<CardDescription>
Kein Vertragsverdacht, sondern eine Merkliste. Die Gruppierung oben braucht
mindestens zwei Zahlungen — eine jährlich abgebuchte Prämie erscheint bei
einem importierten Jahr aber nur einmal. Ausgerechnet Versicherungen werden
oft jährlich gezahlt, deshalb steht hier alles ab{' '}
{formatCent(centZuZahl(EINZELZAHLUNG_AB_CENT))}.
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Empfänger</TableHead>
<TableHead>Datum</TableHead>
<TableHead className="text-right">Betrag</TableHead>
<TableHead>Vertrag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bild.einzelne.map((zahlung) => (
<TableRow key={zahlung.buchungId}>
<TableCell className="font-medium">{zahlung.anzeigename}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDatum(zahlung.datum)}
</TableCell>
<TableCell className="text-right">
<Betrag cent={centZuZahl(zahlung.betragCent)} />
</TableCell>
<TableCell className="text-sm">
{zahlung.vertragBezeichnung ? (
<Badge variant="secondary">{zahlung.vertragBezeichnung}</Badge>
) : (
<span className="text-muted-foreground">keiner</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* --- Bereits erklaert ------------------------------------------ */}
{mitVertrag.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Schon einem Vertrag zugeordnet</CardTitle>
<CardDescription>
Erkannt am Namen der Gesellschaft. Steht hier etwas Falsches, stimmt die
Schreibweise am Vertrag nicht mit der auf dem Kontoauszug überein.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y text-sm">
{mitVertrag.map((gruppe) => (
<li key={gruppe.schluessel} className="space-y-2 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span>
<span className="font-medium">{gruppe.anzeigename}</span>
<span className="ml-2 text-muted-foreground">
{RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]},{' '}
{formatCent(centZuZahl(gruppe.jahresbetragCent))} im Jahr
</span>
</span>
<Button asChild variant="ghost" size="sm">
<Link href={`/vertraege/${gruppe.vertragId}`}>
{gruppe.vertragBezeichnung}
</Link>
</Button>
</div>
{/* Der Vertrag liegt vor - dann belegt er auch die
Zahlungen. Einzeln anzuklicken waeren bei drei
Jahren Laufzeit 36 Klicks. */}
{schreiben && gruppe.offeneBuchungIds.length > 0 && (
<ZahlungenZuordnen
betreuteId={betreute.id}
vertragId={gruppe.vertragId!}
vertragBezeichnung={gruppe.vertragBezeichnung ?? 'Vertrag'}
buchungIds={gruppe.offeneBuchungIds}
/>
)}
{gruppe.offeneBuchungIds.length === 0 && (
<p className="text-xs text-ampel-gruen">
Alle {gruppe.anzahl} Zahlungen sind dem Vertrag zugeordnet und damit
belegt.
</p>
)}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* --- Abgehakt --------------------------------------------------- */}
{bild.abgehakt.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Als „kein Vertrag“ abgehakt</CardTitle>
<CardDescription>
Diese Empfänger bleiben aus der Liste heraus, auch nach dem nächsten Import.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y text-sm">
{bild.abgehakt.map((eintrag) => (
<li
key={eintrag.id}
className="flex flex-wrap items-center justify-between gap-2 py-2"
>
<span>
<span className="font-medium">{eintrag.anzeigename}</span>
{eintrag.begruendung && (
<span className="ml-2 text-muted-foreground">
— {eintrag.begruendung}
</span>
)}
</span>
{schreiben && (
<Zurueckholen betreuteId={betreute.id} eintragId={eintrag.id} />
)}
</li>
))}
</ul>
</CardContent>
</Card>
)}
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Diese Seite wertet nur aus, sie legt nichts an. Gruppiert wird nach dem Empfänger
aus dem Kontoauszug — Groß- und Kleinschreibung, Satzzeichen und die Rechtsform
werden dabei zusammengefasst. Ähnliche, aber verschiedene Namen bleiben bewusst
getrennt: „Stadtwerke Bamberg“ und „Stadtwerke Bayreuth“ sind zwei Rechnungssteller.
</AlertDescription>
</Alert>
</>
)}
</div>
)
}
+27 -2
View File
@@ -1,11 +1,13 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { KeyRound, ShieldCheck } from 'lucide-react'
import { Fingerprint, KeyRound, ShieldCheck } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { erfordereBenutzer } from '@/lib/auth/sitzung'
import { passkeysDesBenutzers, passkeysMoeglich } from '@/lib/auth/passkey'
import { PasskeyVerwaltung } from '@/components/konto/passkey-verwaltung'
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
import { formatDatumZeit } from '@/lib/utils'
import { PasswortFormular } from './passwort-formular'
@@ -16,10 +18,11 @@ export const dynamic = 'force-dynamic'
export default async function KontoSeite() {
const benutzer = await erfordereBenutzer()
const codesUebrig = benutzer.wiederherstellungscodes.length
const passkeys = await passkeysDesBenutzers(benutzer.id)
return (
<div className="max-w-2xl space-y-6">
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten und zweiter Faktor." />
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten, zweiter Faktor und Passkeys." />
<Card>
<CardHeader className="pb-3">
@@ -89,6 +92,28 @@ export default async function KontoSeite() {
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Fingerprint className="size-5" aria-hidden="true" />
Passkeys
</CardTitle>
<CardDescription>
Anmelden mit Fingerabdruck, Gesicht oder Geräte-PIN — ohne E-Mail, Passwort und Code.
Ein Passkey gilt als zweiter Faktor, weil er Besitz des Geräts und dessen Entsperrung
verlangt. Er ist an diese Adresse gebunden: Auf einer nachgebauten Anmeldeseite
funktioniert er nicht.
</CardDescription>
</CardHeader>
<CardContent>
<PasskeyVerwaltung
passkeys={passkeys}
zweiterFaktorAktiv={Boolean(benutzer.totpAktiviertAm)}
moeglich={passkeysMoeglich()}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
+4 -1
View File
@@ -34,6 +34,8 @@ const AKTION_BESCHRIFTUNG: Record<AuditAktion, string> = {
ANMELDUNG_FEHLGESCHLAGEN: 'Anmeldung fehlgeschlagen',
ABMELDUNG: 'abgemeldet',
ZWEITER_FAKTOR_EINGERICHTET: 'zweiter Faktor eingerichtet',
PASSKEY_REGISTRIERT: 'Passkey eingerichtet',
PASSKEY_ENTFERNT: 'Passkey entfernt',
PASSWORT_GEAENDERT: 'Passwort geändert',
ROLLE_GEAENDERT: 'Rolle geändert',
FREIGABE_GEAENDERT: 'Freigabe geändert',
@@ -41,7 +43,8 @@ const AKTION_BESCHRIFTUNG: Record<AuditAktion, string> = {
function variante(aktion: AuditAktion) {
if (aktion === 'ANMELDUNG_FEHLGESCHLAGEN' || aktion === 'ENDGUELTIG_GELOESCHT') return 'rot'
if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT') return 'orange'
if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT' || aktion === 'PASSKEY_ENTFERNT')
return 'orange'
if (aktion === 'ERSTELLT') return 'gruen'
return 'secondary'
}
+68
View File
@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { briefDocxErzeugen } from '@/lib/schreiben/docx'
import { dateinameFuerSchreiben, istSchreibenArt } from '@/lib/schreiben/arten'
import { heuteAlsTag } from '@/lib/fristen/datum'
import { tagAusText } from '@/lib/finanzen/datum'
/**
* Das Schreiben als Word-Datei herunterladen.
*
* Warum eine Route und keine Server Action? Eine Action kann keinen
* Download ausloesen - sie muesste die Datei kodiert zurueckgeben und der
* Browser sie nachbauen. Eine Route liefert sie unmittelbar aus, mit
* richtigem Namen und Typ. Denselben Weg geht schon der Vollexport.
*
* POST und nicht GET, weil der bearbeitete Brieftext mitkommt und in eine
* Adresszeile weder hineinpasst noch hineingehoert: Er stuende sonst im
* Verlauf des Browsers und in jedem Zwischenspeicher.
*/
export const dynamic = 'force-dynamic'
const MIME_DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
export async function POST(anfrage: Request): Promise<Response> {
let formular: FormData
try {
formular = await anfrage.formData()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const betreuteId = String(formular.get('betreuteId') ?? '')
const rohArt = String(formular.get('art') ?? '')
const text = String(formular.get('text') ?? '')
const empfaenger = String(formular.get('empfaengerName') ?? '').trim()
const versandRoh = String(formular.get('versandAm') ?? '').trim()
if (!betreuteId || !istSchreibenArt(rohArt) || text.trim().length < 30) {
return NextResponse.json({ fehler: 'Unvollständige Angaben.' }, { status: 400 })
}
try {
// Zugriff wird hier geprueft und nicht nur auf der Seite davor: Eine
// Route laesst sich unmittelbar aufrufen.
await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen')
} catch (ausnahme) {
if (ausnahme instanceof ZugriffVerweigert) {
return NextResponse.json({ fehler: ausnahme.message }, { status: 403 })
}
throw ausnahme
}
const datum = tagAusText(versandRoh) ?? heuteAlsTag()
const datei = await briefDocxErzeugen(text)
const dateiname = dateinameFuerSchreiben(rohArt, empfaenger || null, datum, 'docx')
return new NextResponse(new Uint8Array(datei), {
headers: {
'Content-Type': MIME_DOCX,
'Content-Disposition': `attachment; filename="${dateiname}"`,
'Content-Length': String(datei.length),
// Ein Schreiben aus einer Betreuungsakte gehoert in keinen
// Zwischenspeicher.
'Cache-Control': 'no-store, must-revalidate',
},
})
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { emailFassung, siehtAusWieEmail } from '@/lib/schreiben/email'
import { emlErzeugen } from '@/lib/schreiben/eml'
import { dateinameFuerSchreiben, istSchreibenArt } from '@/lib/schreiben/arten'
import { heuteAlsTag } from '@/lib/fristen/datum'
/**
* Das Schreiben als .eml-Entwurf herunterladen.
*
* Gleicher Aufbau wie der Word-Download (../docx/route.ts): POST, weil der
* Text mitkommt und nicht in die Adresszeile gehoert; Zugriff wird hier
* geprueft, weil sich eine Route unmittelbar aufrufen laesst.
*/
export const dynamic = 'force-dynamic'
export async function POST(anfrage: Request): Promise<Response> {
let formular: FormData
try {
formular = await anfrage.formData()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const betreuteId = String(formular.get('betreuteId') ?? '')
const rohArt = String(formular.get('art') ?? '')
const text = String(formular.get('text') ?? '')
const betreff = String(formular.get('betreff') ?? '').trim()
const adresse = String(formular.get('empfaengerEmail') ?? '').trim()
const empfaenger = String(formular.get('empfaengerName') ?? '').trim()
if (!betreuteId || !istSchreibenArt(rohArt) || text.trim().length < 30) {
return NextResponse.json({ fehler: 'Unvollständige Angaben.' }, { status: 400 })
}
if (!siehtAusWieEmail(adresse)) {
return NextResponse.json(
{ fehler: 'Für diesen Empfänger ist keine E-Mail-Adresse hinterlegt.' },
{ status: 400 },
)
}
try {
await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen')
} catch (ausnahme) {
if (ausnahme instanceof ZugriffVerweigert) {
return NextResponse.json({ fehler: ausnahme.message }, { status: 403 })
}
throw ausnahme
}
const fassung = emailFassung(text, betreff)
const datei = emlErzeugen({ an: adresse, betreff: fassung.betreff, text: fassung.text })
const dateiname = dateinameFuerSchreiben(rohArt, empfaenger || null, heuteAlsTag(), 'eml')
return new NextResponse(new Uint8Array(datei), {
headers: {
'Content-Type': 'message/rfc822',
'Content-Disposition': `attachment; filename="${dateiname}"`,
'Content-Length': String(datei.length),
'Cache-Control': 'no-store, must-revalidate',
},
})
}
+129
View File
@@ -0,0 +1,129 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AlertCircle, Scale } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf'
import { SchreibenEditor } from '@/components/schreiben/schreiben-editor'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { entwurfBauen } from '@/lib/schreiben/entwurf'
import { SCHREIBEN_EIGENSCHAFTEN, istSchreibenArt } from '@/lib/schreiben/arten'
export const metadata: Metadata = { title: 'Schreiben' }
export const dynamic = 'force-dynamic'
/**
* Ein Schreiben erzeugen, bearbeiten, ausgeben und ablegen.
*
* Eine Seite für alle Arten und alle drei Empfängerwege — statt die
* Bearbeitung dreimal an Vertrag, Vorgang und Freitext nachzubauen. Die
* bestehenden Seiten verlinken hierher.
*
* Der Fall ohne Bezug ist ausdrücklich vorgesehen: Am Anfang einer
* Betreuung schreibt man an Stellen, zu denen es noch keinen Datensatz
* gibt — man fragt ja gerade an, um zu erfahren, ob dort etwas besteht.
*/
export default async function SchreibenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
const benutzer = await erfordereBenutzer('/schreiben/neu')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) redirect('/ersteinrichtung')
const { darfSchreiben } = await erfordereZugang(aktive.id, 'DOKUMENTE', 'lesen')
if (!darfSchreiben('DOKUMENTE')) redirect('/dokumente')
const text = (name: string): string => {
const wert = parameter[name]
return typeof wert === 'string' ? wert.trim() : ''
}
const rohArt = text('art')
const art = istSchreibenArt(rohArt) ? rohArt : 'FREI'
const eigenschaften = SCHREIBEN_EIGENSCHAFTEN[art]
let entwurf
try {
entwurf = await entwurfBauen({
betreuteId: aktive.id,
benutzerName: benutzer.name,
art,
vertragId: text('vertragId') || null,
vorgangId: text('vorgangId') || null,
})
} catch (ausnahme) {
// Fehlender oder fremder Bezug: Das ist kein Absturz, sondern eine
// Angabe, die nicht stimmt. Die Seite sagt es und bietet den freien
// Weg an, statt den Benutzer vor eine Fehlerseite zu setzen.
const meldung =
ausnahme instanceof Error ? ausnahme.message : 'Der Entwurf ließ sich nicht bauen.'
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf titel="Schreiben" beschreibung={eigenschaften.beschriftung} />
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>
{meldung} Du kannst stattdessen ein{' '}
<a href="/schreiben/neu?art=FREI" className="underline">
freies Schreiben
</a>{' '}
erzeugen.
</AlertDescription>
</Alert>
</div>
)
}
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: {
id: true,
name: true,
organisation: true,
strasse: true,
plz: true,
ort: true,
email: true,
},
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 200,
})
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf
titel={eigenschaften.beschriftung}
beschreibung="Bearbeiten, als Word-Datei herunterladen, in die Akte legen — und eine Wiedervorlage setzen, damit das Warten nicht vergessen geht."
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Schreiben' }]}
/>
<SchreibenEditor
betreuteId={aktive.id}
art={art}
entwurf={{
betreff: entwurf.betreff,
text: entwurf.text,
empfaengerName: entwurf.empfaengerName,
wiedervorlageAm: entwurf.wiedervorlageAm,
}}
kontakte={kontakte}
vertragId={entwurf.vertragId}
vorgangId={entwurf.vorgangId}
fristBegruendung={eigenschaften.fristBegruendung}
warnungen={entwurf.warnungen}
/>
<Alert variant="hinweis">
<Scale aria-hidden="true" />
<AlertDescription>
Der vorgeschlagene Wortlaut ist ein Entwurf, keine Rechtsberatung. Bei einem
genehmigungspflichtigen Geschäft gehört die Genehmigung des Betreuungsgerichts vor den
Versand — nicht danach.
</AlertDescription>
</Alert>
</div>
)
}
+66 -2
View File
@@ -1,11 +1,62 @@
import { redirect } from 'next/navigation'
import { Info } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { SeitenKopf } from '@/components/seiten-kopf'
import { LEERER_VERTRAG, VertragFormular } from '@/components/vertraege/vertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { alsZahlRhythmus, type Rhythmus } from '@/lib/finanzen/wiederkehrend'
import { betragFormatieren } from '@/lib/finanzen/betrag'
export const dynamic = 'force-dynamic'
export default async function VertragAnlegen() {
/**
* Uebernimmt eine Vorbelegung aus der Adresszeile.
*
* Sie kommt von "Wiederkehrende Zahlungen": Dort steht schon, wer da
* abbucht und wie viel. Diese Angaben noch einmal abzutippen waere genau
* die Arbeit, die die Auswertung ersparen soll.
*
* Uebernommen wird nur, was aus dem Kontoauszug ablesbar ist - Gesellschaft,
* Beitrag, Rhythmus. Die Bezeichnung und die Vertragsart bleiben leer: Was
* fuer ein Vertrag das ist, steht nicht im Auszug, und eine geratene
* Vorbelegung wird uebersehen statt korrigiert.
*/
function vorbelegung(parameter: Record<string, string | string[] | undefined>) {
const text = (name: string): string => {
const wert = parameter[name]
return typeof wert === 'string' ? wert.trim() : ''
}
const werte = { ...LEERER_VERTRAG }
let etwasUebernommen = false
const gesellschaft = text('gesellschaft')
if (gesellschaft) {
werte.gesellschaft = gesellschaft.slice(0, 200)
etwasUebernommen = true
}
const cent = text('beitragCent')
if (/^[0-9]{1,15}$/.test(cent)) {
werte.beitrag = betragFormatieren(BigInt(cent))
etwasUebernommen = true
}
const rhythmus = alsZahlRhythmus(text('rhythmus') as Rhythmus)
if (rhythmus) {
werte.rhythmus = rhythmus as typeof werte.rhythmus
etwasUebernommen = true
}
return { werte, etwasUebernommen }
}
export default async function VertragAnlegen({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
const benutzer = await erfordereBenutzer('/vertraege/neu')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
@@ -13,6 +64,8 @@ export default async function VertragAnlegen() {
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect('/vertraege')
const { werte, etwasUebernommen } = vorbelegung(parameter)
return (
<div className="space-y-6">
<SeitenKopf
@@ -25,7 +78,18 @@ export default async function VertragAnlegen() {
]}
/>
<VertragFormular betreuteId={aktive.id} werte={LEERER_VERTRAG} />
{etwasUebernommen && (
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertDescription>
Gesellschaft, Beitrag und Rhythmus sind aus den wiederkehrenden Zahlungen
übernommen. Bezeichnung und Art musst du selbst wählen — was für ein Vertrag das
ist, steht nicht im Kontoauszug.
</AlertDescription>
</Alert>
)}
<VertragFormular betreuteId={aktive.id} werte={werte} />
</div>
)
}
+9 -1
View File
@@ -1,6 +1,6 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Clock } from 'lucide-react'
import { ArrowLeft, FileDown, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -245,6 +245,14 @@ export default async function VorgangSeite({ params }: { params: Promise<{ id: s
darfSchreiben={schreiben}
/>
)}
{schreiben && (
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}>
<FileDown aria-hidden="true" />
Als Word-Datei und in die Akte
</Link>
</Button>
)}
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
</>
}
+10 -2
View File
@@ -4,6 +4,8 @@ import { ShieldCheck } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { aktuellerBenutzer } from '@/lib/auth/sitzung'
import { db } from '@/lib/db'
import { passkeysMoeglich } from '@/lib/auth/passkey'
import { PasskeyAnmeldung } from '@/components/passkey-anmeldung'
import { AnmeldeFormular } from './formular'
export const metadata: Metadata = { title: 'Anmelden' }
@@ -32,10 +34,14 @@ export default async function AnmeldenSeite({
<CardTitle>Anmelden</CardTitle>
<CardDescription>
E-Mail, Passwort und der Code aus deiner Authenticator-App — alle drei zusammen.
{passkeysMoeglich() && ' Oder in einem Schritt mit deinem Passkey.'}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-5">
<AnmeldeFormular weiter={weiter} />
{/* Nur wenn der Server ueber https laeuft - sonst gibt der
Browser WebAuthn gar nicht frei. */}
{passkeysMoeglich() && <PasskeyAnmeldung weiter={weiter} />}
</CardContent>
</Card>
@@ -43,7 +49,9 @@ export default async function AnmeldenSeite({
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>
Hier liegen Gesundheits- und Vermögensdaten einer betreuten Person. Der zweite Faktor ist
deshalb Pflicht und lässt sich nicht abschalten.
deshalb Pflicht und lässt sich nicht abschalten. Ein Passkey erfüllt ihn ebenfalls: Er
verlangt Fingerabdruck, Gesicht oder Geräte-PIN — und lässt sich, anders als ein
abgetippter Code, nicht auf einer nachgebauten Seite abgreifen.
</p>
</div>
</>
+112
View File
@@ -12,6 +12,8 @@ import {
versuchFesthalten,
} from '@/lib/auth/ratelimit'
import { protokollieren } from '@/lib/audit'
import { PasskeyFehler, anmeldungPruefen } from '@/lib/auth/passkey'
import type { AuthenticationResponseJSON } from '@simplewebauthn/server'
/**
* Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt.
@@ -30,6 +32,8 @@ export type AnmeldeGrund =
| 'gesperrt'
| 'konto-inaktiv'
| 'code-falsch'
/** Passkey-Fehler; der genaue Grund haengt mit Doppelpunkt daran. */
| 'passkey'
/**
* Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code".
@@ -45,6 +49,19 @@ export class AnmeldeFehler extends CredentialsSignin {
}
}
/**
* Wie AnmeldeFehler, nur fuer Passkeys: der Grund wird als "passkey:<grund>"
* kodiert, damit die Anmeldeseite die passende Erklaerung zeigen kann.
*/
export class PasskeyAnmeldeFehler extends CredentialsSignin {
code: string
constructor(grund: string) {
super(grund)
this.code = `passkey:${grund}`
}
}
function ipAusAnfrage(anfrage: Request | undefined): string | null {
if (!anfrage) return null
const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
@@ -203,6 +220,101 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}
},
}),
/**
* Anmeldung mit Passkey - ein Schritt, ohne E-Mail und ohne Passwort.
*
* Die vollstaendige Pruefung laeuft hier drin und nicht in der Server
* Action davor. Das ist Absicht: Wuerde die Action pruefen und das
* Ergebnis anschliessend an signIn() weiterreichen, gaebe es einen
* Zwischenzustand, dem authorize() glauben muesste. Genau solche
* "war schon geprueft"-Uebergaben sind die Stelle, an der sich
* Anmeldungen faelschen lassen. Hier gibt es sie nicht.
*/
Credentials({
id: 'passkey',
name: 'Passkey',
credentials: {
antwort: { label: 'Antwort des Authenticators', type: 'text' },
},
async authorize(daten, anfrage) {
const ip = ipAusAnfrage(anfrage)
let antwort: AuthenticationResponseJSON
try {
antwort = JSON.parse(String(daten?.antwort ?? '')) as AuthenticationResponseJSON
} catch {
throw new PasskeyAnmeldeFehler('ungueltig')
}
let geprueft
try {
geprueft = await anmeldungPruefen(antwort)
} catch (fehler) {
// Fehlversuche zaehlen mit, auch wenn der Zugang unbekannt ist -
// sonst waere der Passkey-Weg ein Schlupfloch an der Sperre vorbei.
await versuchFesthalten('(passkey)', ip, false, 'passkey-ungueltig')
await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
entitaet: 'User',
userEmail: '(passkey)',
bezeichnung:
fehler instanceof PasskeyFehler ? `Passkey: ${fehler.grund}` : 'Passkey abgewiesen',
})
throw new PasskeyAnmeldeFehler(
fehler instanceof PasskeyFehler ? fehler.grund : 'ungueltig',
)
}
const { benutzer, passkey } = geprueft
// Die Sperre gilt fuer den Zugang, nicht fuer das Verfahren. Sie
// wird erst hier geprueft, weil vorher niemand weiss, wer sich
// anmeldet - erraten laesst sich ein Passkey ohnehin nicht.
const sperre = await anmeldungErlaubt(benutzer.email, ip)
if (sperre.gesperrt) {
await versuchFesthalten(benutzer.email, ip, false, 'gesperrt')
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
}
if (!benutzer.aktiv || benutzer.geloeschtAm) {
await versuchFesthalten(benutzer.email, ip, false, 'konto-inaktiv')
throw new AnmeldeFehler('konto-inaktiv')
}
await Promise.all([
versuchFesthalten(benutzer.email, ip, true, 'passkey'),
fehlversucheZuruecksetzen(benutzer.email),
db.user.update({
where: { id: benutzer.id },
data: { letzteAnmeldung: new Date() },
}),
protokollieren({
aktion: 'ANMELDUNG',
entitaet: 'User',
entitaetId: benutzer.id,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
bezeichnung: `Mit Passkey „${passkey.name}“`,
}),
])
return {
id: benutzer.id,
email: benutzer.email,
name: benutzer.name,
rolle: benutzer.rolle,
sitzungsGeneration: benutzer.sitzungsGeneration,
// Ein Passkey mit Nutzerverifikation IST der zweite Faktor. Ohne
// eingerichtetes TOTP laesst er sich gar nicht erst anlegen, das
// Flag ist hier also immer wahr - es wird trotzdem aus dem
// Benutzer gelesen und nicht fest gesetzt.
zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm),
}
},
}),
],
callbacks: {
+15 -2
View File
@@ -56,8 +56,21 @@ export function AppHuelle({
Zum Inhalt springen
</a>
{/* --- Seitenleiste am Schreibtisch --------------------------------- */}
<aside className="no-print hidden w-72 shrink-0 border-r bg-card lg:flex lg:flex-col">
{/* --- Seitenleiste am Schreibtisch ---------------------------------
sticky + h-dvh, nicht einfach mitwachsen lassen: Ohne feste Hoehe
wird die Leiste so hoch wie die laengste Seite. Der Fuss mit dem
Benutzermenue - und damit der einzige Weg zu "Mein Konto",
Passwort, zweitem Faktor und Passkeys - landet dann am Ende der
Seite statt am unteren Bildschirmrand. Auf /heute waren das 1632
Pixel bei 900 Pixel Fensterhoehe: unerreichbar, ohne dass etwas
kaputt aussah.
self-start gehoert dazu, weil ein Flex-Kind sonst auf die volle
Zeilenhoehe gestreckt wird und die feste Hoehe wieder verliert.
Die Navigation dazwischen hat bereits overflow-y-auto und scrollt
bei Bedarf in sich selbst. */}
<aside className="no-print hidden w-72 shrink-0 border-r bg-card lg:sticky lg:top-0 lg:flex lg:h-dvh lg:flex-col lg:self-start">
<div className="flex h-16 items-center border-b px-5">
<Link href="/heute" className="flex items-center gap-2 font-semibold">
<span
+28 -2
View File
@@ -1,5 +1,5 @@
import Link from 'next/link'
import { ReceiptText } from 'lucide-react'
import { Repeat, ReceiptText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelZeile } from '@/components/dashboard/kachel'
@@ -17,7 +17,10 @@ import { formatCent, formatDatum } from '@/lib/utils'
export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) {
if (!daten) return null
const offen = daten.belegeOhneBuchungGesamt + daten.buchungenOhneBelegGesamt
const offen =
daten.belegeOhneBuchungGesamt +
daten.buchungenOhneBelegGesamt +
daten.wiederkehrendOhneVertrag
return (
<Kachel
@@ -72,6 +75,29 @@ export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) {
</section>
)}
{daten.wiederkehrendOhneVertrag > 0 && (
<section aria-labelledby="wiederkehrend-ohne-vertrag">
<h3
id="wiederkehrend-ohne-vertrag"
className="mb-1 text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
Regelmäßige Abbuchungen ohne Vertrag ({daten.wiederkehrendOhneVertrag})
</h3>
<KachelZeile href="/finanzen/wiederkehrend">
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 font-medium">
<Repeat className="size-4 shrink-0" aria-hidden="true" />
{daten.wiederkehrendOhneVertrag} Empfänger ansehen
</span>
<span className="block text-sm text-muted-foreground">
Wer regelmäßig abbucht, hat einen Vertrag — und der fehlt dann im
Vermögensverzeichnis.
</span>
</span>
</KachelZeile>
</section>
)}
{daten.buchungenOhneBelegGesamt > 0 && (
<section aria-labelledby="buchungen-ohne-beleg">
<h3
+40 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react'
import Link from 'next/link'
import { AlertTriangle, FileWarning, Paperclip } from 'lucide-react'
import { AlertTriangle, FileCheck, FileWarning, Paperclip } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { cn, formatCent } from '@/lib/utils'
@@ -104,10 +104,19 @@ export function Belegzeichen({
belegTitel,
begruendung,
belegId,
vertragBezeichnung = null,
vertragAutomatisch = false,
betragZuPruefen = false,
}: {
belegId: string | null
belegTitel: string | null
begruendung: string | null
/** Dauerzahlung: gesetzt, wenn der Vertrag als Beleg gilt. */
vertragBezeichnung?: string | null
/** Beim Import automatisch zugeordnet - eine Vermutung, kein Urteil. */
vertragAutomatisch?: boolean
/** Zugeordnet, aber der Betrag weicht ab. */
betragZuPruefen?: boolean
}) {
if (belegId) {
return (
@@ -118,6 +127,36 @@ export function Belegzeichen({
)
}
// Zugeordnet, aber der Betrag passt nicht. Steht vor allem anderen: Das
// ist der Fall, den jemand ansehen soll.
if (betragZuPruefen) {
return (
<Badge
variant="rot"
title="Der Betrag weicht deutlich vom hinterlegten Beitrag ab. Meist eine Beitragsanpassung, die am Vertrag noch fehlt."
>
<AlertTriangle className="size-3.5" aria-hidden="true" />
Betrag prüfen
</Badge>
)
}
if (vertragBezeichnung) {
return (
<Badge
variant="gruen"
title={
vertragAutomatisch
? `Beim Import automatisch dem Vertrag „${vertragBezeichnung}“ zugeordnet. Bitte einmal durchsehen.`
: `Dauerzahlung zu Vertrag „${vertragBezeichnung}“.`
}
>
<FileCheck className="size-3.5" aria-hidden="true" />
{vertragAutomatisch ? 'Vertrag (automatisch)' : 'durch Vertrag belegt'}
</Badge>
)
}
if (begruendung && begruendung.trim() !== '') {
return (
<Badge variant="gelb" title={begruendung}>
+34 -1
View File
@@ -33,6 +33,12 @@ export type BelegAuswahl = {
titel: string
}
export type VertragAuswahl = {
id: string
bezeichnung: string
gesellschaft: string | null
}
export type BuchungWerte = {
id: string
kontoId: string | null
@@ -45,6 +51,7 @@ export type BuchungWerte = {
verwendungszweck: string | null
belegId: string | null
belegFehltBegruendung: string | null
vertragId: string | null
betrifftBetreute: boolean
vermoegenswirksam: boolean
}
@@ -65,6 +72,7 @@ export function Buchungsformular({
betreuteId,
konten,
belege,
vertraege,
buchung,
standardDatum,
schnellerfassung = false,
@@ -72,6 +80,7 @@ export function Buchungsformular({
betreuteId: string
konten: KontoAuswahl[]
belege: BelegAuswahl[]
vertraege: VertragAuswahl[]
buchung?: BuchungWerte
/** Vorbelegung des Datumsfeldes bei einer neuen Buchung. */
standardDatum?: string
@@ -87,6 +96,7 @@ export function Buchungsformular({
)
const [betrifftBetreute, setBetrifftBetreute] = React.useState(buchung?.betrifftBetreute ?? true)
const [belegId, setBelegId] = React.useState(buchung?.belegId ?? '')
const [vertragId, setVertragId] = React.useState(buchung?.vertragId ?? '')
const konto = konten.find((k) => k.id === kontoId)
const vermischt = Boolean(konto?.istBetreuerkonto) && betrifftBetreute
@@ -275,7 +285,30 @@ export function Buchungsformular({
</Select>
</Feld>
{belegId === '' && (
{belegId === '' && vertraege.length > 0 && (
<Feld
id="vertragId"
beschriftung="Oder: Dauerzahlung zu einem Vertrag"
erklaerung="Bei einem Dauerschuldverhältnis ist der Vertrag der Beleg — für zwölf gleiche Lastschriften gibt es keine zwölf Quittungen, sondern eine Police. Weicht der Betrag deutlich vom hinterlegten Beitrag ab, wird die Buchung trotzdem zur Prüfung markiert."
fehler={zustand.felder?.vertragId}
>
<Select
name="vertragId"
value={vertragId}
onChange={(ereignis) => setVertragId(ereignis.target.value)}
>
<option value="">– keine Dauerzahlung –</option>
{vertraege.map((vertrag) => (
<option key={vertrag.id} value={vertrag.id}>
{vertrag.bezeichnung}
{vertrag.gesellschaft ? ` (${vertrag.gesellschaft})` : ''}
</option>
))}
</Select>
</Feld>
)}
{belegId === '' && vertragId === '' && (
<Feld
id="belegFehltBegruendung"
beschriftung="Warum fehlt der Beleg?"
@@ -0,0 +1,177 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2, Link2, Undo2, XCircle } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import {
zahlungenVertragZuordnen,
zahlungsgruppeAbhaken,
zahlungsgruppeZurueckholen,
type Formularzustand,
} from '@/server/actions/finanzen'
/**
* „Kein Vertrag" — eine Gruppe dauerhaft aus der Liste nehmen.
*
* Die Begründung ist freiwillig, steht aber im Formular ganz vorn. In einem
* Jahr weiß niemand mehr, warum „Stadtwerke Bamberg" abgehakt wurde, und
* dann fängt die Prüfung von vorn an. Ein halber Satz jetzt spart das.
*/
export function AlsGeklaertAbhaken({
betreuteId,
schluessel,
anzeigename,
}: {
betreuteId: string
schluessel: string
anzeigename: string
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
zahlungsgruppeAbhaken,
{},
)
const [offen, setOffen] = React.useState(false)
if (!offen) {
return (
<div className="space-y-2">
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Button type="button" variant="ghost" size="sm" onClick={() => setOffen(true)}>
<XCircle aria-hidden="true" />
Kein Vertrag
</Button>
</div>
)
}
return (
<form action={absenden} className="space-y-2 rounded-md border p-3">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="schluessel" value={schluessel} />
<input type="hidden" name="anzeigename" value={anzeigename} />
<p className="text-sm">
„{anzeigename}“ verschwindet aus der Liste. Zurückholen geht jederzeit.
</p>
<Input
name="begruendung"
placeholder="Warum kein Vertrag? (z. B. Miete, Strom, Steuer)"
maxLength={200}
autoFocus
/>
<div className="flex gap-2">
<Absendeknopf size="sm" wartetext="Wird abgehakt …">
Abhaken
</Absendeknopf>
<Button type="button" variant="ghost" size="sm" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)
}
/** Holt eine abgehakte Gruppe zurück in die Liste. */
export function Zurueckholen({
betreuteId,
eintragId,
}: {
betreuteId: string
eintragId: string
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
zahlungsgruppeZurueckholen,
{},
)
return (
<form action={absenden} className="inline">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="eintragId" value={eintragId} />
{zustand.fehler && (
<span className="mr-2 text-xs text-destructive">{zustand.fehler}</span>
)}
<Absendeknopf variant="ghost" size="sm" wartetext="…">
<Undo2 aria-hidden="true" />
Zurückholen
</Absendeknopf>
</form>
)
}
/**
* Zeigt die Rückmeldung der letzten Aktion.
*
* Bewusst getrennt von den Knöpfen: Nach dem Abhaken verschwindet die
* ganze Zeile samt Knopf, und mit ihr die Erfolgsmeldung. Sie hier
* anzuzeigen wäre sinnlos — deshalb steht der Erfolg als kurzer Hinweis
* oben auf der Seite, sobald die Liste neu geladen ist.
*/
export function Rueckmeldung({ text }: { text: string | null }) {
if (!text) return null
return (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{text}</AlertDescription>
</Alert>
)
}
/**
* Ordnet alle noch offenen Buchungen einer Gruppe dem Vertrag zu.
*
* Der Sinn dieses Knopfes ist die Menge: Eine monatliche Prämie über drei
* Jahre sind 36 Buchungen. Sie einzeln anzuklicken wäre genau die stumpfe
* Arbeit, die diese Zuordnung abschaffen soll.
*/
export function ZahlungenZuordnen({
betreuteId,
vertragId,
vertragBezeichnung,
buchungIds,
}: {
betreuteId: string
vertragId: string
vertragBezeichnung: string
buchungIds: string[]
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
zahlungenVertragZuordnen,
{},
)
if (buchungIds.length === 0) return null
return (
<form action={absenden} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vertragId" value={vertragId} />
<input type="hidden" name="buchungIds" value={buchungIds.join(',')} />
{zustand.fehler && (
<span className="text-xs text-destructive">{zustand.fehler}</span>
)}
{zustand.erfolg && (
<span className="text-xs text-ampel-gruen">{zustand.erfolg}</span>
)}
<Absendeknopf variant="outline" size="sm" wartetext="Wird zugeordnet …">
<Link2 aria-hidden="true" />
{buchungIds.length} Zahlung{buchungIds.length === 1 ? '' : 'en'} als belegt zuordnen
</Absendeknopf>
<span className="text-xs text-muted-foreground">zu „{vertragBezeichnung}“</span>
</form>
)
}
+277
View File
@@ -0,0 +1,277 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
AlertCircle,
CheckCircle2,
Fingerprint,
Loader2,
Plus,
Smartphone,
Trash2,
} from 'lucide-react'
import {
passkeyEinrichtungAbschliessen,
passkeyEinrichtungStarten,
passkeyEntfernen,
} from '@/server/actions/passkey'
import {
browserSupportsWebAuthn,
passkeyBrowserfehler,
startRegistration,
} from '@/lib/passkey-browser'
import type { PasskeyUebersicht } from '@/lib/auth/passkey'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
import { formatDatum, formatDatumZeit } from '@/lib/utils'
/**
* Verwaltung der eigenen Passkeys unter "Mein Konto".
*
* Die Liste kommt vom Server; nach jeder Aenderung wird die Seite neu
* geladen (router.refresh) statt den Zustand hier mitzufuehren. Bei einer
* Handvoll Eintraegen ist das die ehrlichere Loesung: Was auf dem Bildschirm
* steht, steht auch so in der Datenbank.
*/
export function PasskeyVerwaltung({
passkeys,
zweiterFaktorAktiv,
moeglich,
}: {
passkeys: PasskeyUebersicht[]
zweiterFaktorAktiv: boolean
/** Ob der Server Passkeys ueberhaupt anbieten kann (https). */
moeglich: boolean
}) {
const router = useRouter()
const [browserKann, setBrowserKann] = useState(false)
const [name, setName] = useState('')
const [laeuft, setLaeuft] = useState(false)
const [entfernt, setEntfernt] = useState<string | null>(null)
const [fehler, setFehler] = useState<string | null>(null)
const [erfolg, setErfolg] = useState<string | null>(null)
const [nachfrage, setNachfrage] = useState<string | null>(null)
useEffect(() => {
setBrowserKann(browserSupportsWebAuthn())
}, [])
async function einrichten() {
setFehler(null)
setErfolg(null)
if (name.trim() === '') {
setFehler('Bitte gib dem Gerät zuerst einen Namen.')
return
}
setLaeuft(true)
try {
const start = await passkeyEinrichtungStarten()
if (!start.ok) {
setFehler(start.fehler)
return
}
const antwort = await startRegistration({ optionsJSON: start.optionen })
const ergebnis = await passkeyEinrichtungAbschliessen(name, JSON.stringify(antwort))
if (!ergebnis.ok) {
setFehler(ergebnis.fehler)
return
}
setName('')
setErfolg(ergebnis.meldung ?? 'Passkey eingerichtet.')
router.refresh()
} catch (ausnahme) {
setFehler(passkeyBrowserfehler(ausnahme, true))
} finally {
setLaeuft(false)
}
}
async function entfernen(id: string) {
setFehler(null)
setErfolg(null)
setNachfrage(null)
setEntfernt(id)
try {
const ergebnis = await passkeyEntfernen(id)
if (!ergebnis.ok) {
setFehler(ergebnis.fehler)
return
}
setErfolg(ergebnis.meldung ?? 'Passkey entfernt.')
router.refresh()
} finally {
setEntfernt(null)
}
}
if (!moeglich) {
return (
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Passkeys brauchen eine verschlüsselte Verbindung. Diese Installation ist nicht über
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">https</code>
erreichbar — sobald sie es ist, erscheint hier die Verwaltung.
</AlertDescription>
</Alert>
)
}
if (!zweiterFaktorAktiv) {
return (
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Richte zuerst die Authenticator-App ein. Ein Passkey hängt an einem Gerät — ginge das
verloren und wäre er der einzige Weg hinein, käme niemand mehr an die Akte. Der Passkey
ist die bequeme Tür, nicht die einzige.
</AlertDescription>
</Alert>
)
}
return (
<div className="space-y-4">
{fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
{erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{erfolg}</AlertDescription>
</Alert>
)}
{passkeys.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch kein Passkey eingerichtet. Du meldest dich weiterhin mit E-Mail, Passwort und Code an
— das bleibt auch danach möglich.
</p>
) : (
<ul className="divide-y rounded-md border">
{passkeys.map((passkey) => (
<li key={passkey.id} className="flex flex-wrap items-start gap-3 p-3">
<Smartphone
className="mt-0.5 size-5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="font-medium">{passkey.name}</p>
<p className="text-xs text-muted-foreground">
Eingerichtet am {formatDatum(passkey.erstelltAm)}
{passkey.zuletztBenutztAm
? ` · zuletzt benutzt ${formatDatumZeit(passkey.zuletztBenutztAm)}`
: ' · noch nicht benutzt'}
</p>
{passkey.geraeteArt === 'singleDevice' && (
<p className="mt-1 text-xs text-muted-foreground">
Liegt nur auf diesem Gerät. Geht es verloren, ist auch der Passkey weg.
</p>
)}
</div>
{passkey.gesichert && (
<Badge variant="secondary" className="shrink-0">
gesichert
</Badge>
)}
{nachfrage === passkey.id ? (
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => void entfernen(passkey.id)}
disabled={entfernt === passkey.id}
>
{entfernt === passkey.id ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : null}
Wirklich entfernen
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setNachfrage(null)}
>
Abbrechen
</Button>
</div>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => setNachfrage(passkey.id)}
aria-label={`Passkey „${passkey.name}“ entfernen`}
>
<Trash2 aria-hidden="true" />
Entfernen
</Button>
)}
</li>
))}
</ul>
)}
{browserKann ? (
<div className="space-y-3 rounded-md border border-dashed p-3">
<Feld
id="passkey-name"
beschriftung="Neues Gerät"
erklaerung="Ein Name, an dem du es später wiedererkennst — etwa „iPhone“ oder „Laptop Büro“."
>
<Input
id="passkey-name"
value={name}
onChange={(ereignis) => setName(ereignis.target.value)}
maxLength={60}
placeholder="iPhone"
autoComplete="off"
/>
</Feld>
<Button type="button" onClick={() => void einrichten()} disabled={laeuft}>
{laeuft ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Warte auf das Gerät …
</>
) : (
<>
<Plus aria-hidden="true" />
Passkey einrichten
</>
)}
</Button>
</div>
) : (
<Alert variant="hinweis">
<Fingerprint aria-hidden="true" />
<AlertDescription>
Dieser Browser kann keine Passkeys. Auf einem anderen Gerät klappt es vermutlich — die
bereits eingerichteten bleiben davon unberührt.
</AlertDescription>
</Alert>
)}
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertCircle, Fingerprint, Loader2 } from 'lucide-react'
import { passkeyAnmeldungAbschliessen, passkeyAnmeldungStarten } from '@/server/actions/passkey'
import {
browserSupportsWebAuthn,
passkeyBrowserfehler,
startAuthentication,
} from '@/lib/passkey-browser'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
/**
* Knopf "Mit Passkey anmelden" auf der Anmeldeseite.
*
* Der Ablauf ist dreiteilig: Optionen vom Server holen, das Geraet fragen,
* die Antwort zurueckschicken. Gelingt der letzte Schritt, leitet die Server
* Action weiter - dieser Baustein sieht das Ergebnis dann gar nicht mehr.
*
* Sichtbar ist der Knopf nur, wenn der Server ihn anbietet (https) UND der
* Browser WebAuthn kann. Die zweite Pruefung laeuft erst nach dem ersten
* Rendern: Auf dem Server gibt es kein navigator-Objekt, und ein Knopf, der
* beim Laden verschwindet, ist unangenehmer als einer, der kurz spaeter
* erscheint.
*/
export function PasskeyAnmeldung({ weiter }: { weiter: string }) {
const [laeuft, setLaeuft] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const [browserKann, setBrowserKann] = useState(false)
useEffect(() => {
setBrowserKann(browserSupportsWebAuthn())
}, [])
async function anmelden() {
setFehler(null)
setLaeuft(true)
try {
const start = await passkeyAnmeldungStarten()
if (!start.ok) {
setFehler(start.fehler)
return
}
const antwort = await startAuthentication({
optionsJSON: start.optionen,
})
const ergebnis = await passkeyAnmeldungAbschliessen(JSON.stringify(antwort), weiter)
// Bei Erfolg wird gar nichts zurueckgegeben - die Action leitet weiter.
if (!ergebnis.ok) setFehler(ergebnis.fehler)
} catch (ausnahme) {
setFehler(passkeyBrowserfehler(ausnahme))
} finally {
setLaeuft(false)
}
}
if (!browserKann) return null
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wide text-muted-foreground">oder</span>
<span className="h-px flex-1 bg-border" />
</div>
{fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{fehler}</AlertDescription>
</Alert>
)}
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={anmelden}
disabled={laeuft}
>
{laeuft ? (
<>
<Loader2 className="animate-spin" aria-hidden="true" />
Warte auf das Gerät …
</>
) : (
<>
<Fingerprint aria-hidden="true" />
Mit Passkey anmelden
</>
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
Fingerabdruck, Gesicht oder Geräte-PIN — ohne E-Mail und Passwort.
</p>
</div>
)
}
@@ -0,0 +1,513 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import Link from 'next/link'
import {
AlertCircle,
CheckCircle2,
Copy,
FileDown,
FolderInput,
Info,
Mail,
UserPlus,
} from 'lucide-react'
import { Alert, AlertDescription } 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 { Textarea } from '@/components/ui/textarea'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import {
kontaktAusAnschrift,
schreibenAblegen,
type SchreibenZustand,
} from '@/server/actions/schreiben'
import type { SchreibenArt } from '@/lib/schreiben/arten'
import { emailFassung, mailtoLink, siehtAusWieEmail } from '@/lib/schreiben/email'
export type KontaktAuswahl = {
id: string
name: string
organisation: string | null
strasse: string | null
plz: string | null
ort: string | null
email: string | null
}
/**
* Schreiben bearbeiten, herunterladen, ablegen.
*
* Der Text steht in einem gewöhnlichen Textfeld und ist vollständig
* bearbeitbar. Das ist Absicht: Jeder Fall hat eine Besonderheit, die in
* den Brief gehört. Ein Formulartext, den man nicht mehr ändern kann,
* wird früher oder später falsch abgeschickt.
*/
export function SchreibenEditor({
betreuteId,
art,
entwurf,
kontakte,
vertragId,
vorgangId,
fristBegruendung,
warnungen,
}: {
betreuteId: string
art: SchreibenArt
entwurf: { betreff: string; text: string; empfaengerName: string; wiedervorlageAm: string }
kontakte: KontaktAuswahl[]
vertragId: string | null
vorgangId: string | null
fristBegruendung: string | null
warnungen: string[]
}) {
const [text, setText] = React.useState(entwurf.text)
const [betreff, setBetreff] = React.useState(entwurf.betreff)
const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName)
const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState('')
const [empfaengerEmail, setEmpfaengerEmail] = React.useState('')
const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF')
const [betreffKopiert, setBetreffKopiert] = React.useState(false)
const [mailKopiert, setMailKopiert] = React.useState(false)
const [versandAm, setVersandAm] = React.useState(new Date().toISOString().slice(0, 10))
const [wiedervorlageAm, setWiedervorlageAm] = React.useState(entwurf.wiedervorlageAm)
const [kopiert, setKopiert] = React.useState(false)
const [ablageZustand, ablegen] = useActionState<SchreibenZustand, FormData>(
schreibenAblegen,
{},
)
const [kontaktZustand, kontaktAnlegen] = useActionState<SchreibenZustand, FormData>(
kontaktAusAnschrift,
{},
)
/** Gemeinsame versteckte Felder für beide Formulare und den Download. */
function VersteckteFelder() {
return (
<>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="art" value={art} />
<input type="hidden" name="betreff" value={betreff} />
<input type="hidden" name="text" value={text} />
<input type="hidden" name="empfaengerName" value={empfaengerName} />
<input type="hidden" name="empfaengerAnschrift" value={empfaengerAnschrift} />
<input type="hidden" name="versandAm" value={versandAm} />
<input type="hidden" name="wiedervorlageAm" value={wiedervorlageAm} />
<input type="hidden" name="vertragId" value={vertragId ?? ''} />
<input type="hidden" name="vorgangId" value={vorgangId ?? ''} />
<input type="hidden" name="versandweg" value={versandweg} />
<input type="hidden" name="empfaengerEmail" value={empfaengerEmail} />
</>
)
}
function kontaktUebernehmen(id: string) {
const kontakt = kontakte.find((k) => k.id === id)
if (!kontakt) return
setEmpfaengerName(kontakt.organisation ?? kontakt.name)
setEmpfaengerAnschrift(
[kontakt.strasse, [kontakt.plz, kontakt.ort].filter(Boolean).join(' ')]
.filter(Boolean)
.join('\n'),
)
setEmpfaengerEmail(kontakt.email ?? '')
}
async function textKopieren() {
try {
await navigator.clipboard.writeText(text)
setKopiert(true)
window.setTimeout(() => setKopiert(false), 2500)
} catch {
/* Zwischenablage gesperrt - dann markiert man eben von Hand. */
}
}
const abgelegt = Boolean(ablageZustand.erfolg)
/**
* Die E-Mail-Fassung: Betreff und Rumpf getrennt, ohne Briefkopf.
*
* Abgeleitet aus dem AKTUELLEN Text im Feld - wer den Brief bearbeitet
* hat, bekommt die bearbeitete Fassung, nicht den Ursprungsentwurf.
*/
function EmailAusgabe() {
const fassung = emailFassung(text, betreff)
const adresseGueltig = siehtAusWieEmail(empfaengerEmail)
const mailto = adresseGueltig ? mailtoLink(empfaengerEmail, fassung) : null
async function kopieren(inhalt: string, merker: (wert: boolean) => void) {
try {
await navigator.clipboard.writeText(inhalt)
merker(true)
window.setTimeout(() => merker(false), 2500)
} catch {
/* Zwischenablage gesperrt - dann markiert man eben von Hand. */
}
}
return (
<div className="space-y-4 rounded-md border p-4">
<Feld
id="emailBetreff"
beschriftung="Betreff der E-Mail"
erklaerung="Aus der Betreffzeile des Briefes übernommen."
>
<div className="flex gap-2">
<Input id="emailBetreff" value={fassung.betreff} readOnly className="flex-1" />
<Button
type="button"
variant="outline"
onClick={() => void kopieren(fassung.betreff, setBetreffKopiert)}
>
<Copy aria-hidden="true" />
{betreffKopiert ? 'Kopiert' : 'Kopieren'}
</Button>
</div>
</Feld>
<Feld
id="emailText"
beschriftung="Text der E-Mail"
erklaerung="Ohne Absenderblock, Anschrift und Datumszeile — die gehören in den Brief, nicht in die Mail. Der Nachweis der Bestellung bleibt drin."
>
<div className="space-y-2">
<Textarea
id="emailText"
value={fassung.text}
readOnly
rows={14}
className="font-mono text-sm leading-relaxed"
/>
<Button
type="button"
variant="outline"
onClick={() => void kopieren(fassung.text, setMailKopiert)}
>
<Copy aria-hidden="true" />
{mailKopiert ? 'Kopiert' : 'Text kopieren'}
</Button>
</div>
</Feld>
<div className="flex flex-wrap items-center gap-2">
{mailto && (
<Button asChild variant="outline">
<a href={mailto}>
<Mail aria-hidden="true" />
Im Mailprogramm öffnen
</a>
</Button>
)}
{adresseGueltig && (
<form action="/schreiben/eml" method="post" className="inline">
<VersteckteFelder />
<Button type="submit" variant="outline">
<FileDown aria-hidden="true" />
Als E-Mail-Entwurf (.eml)
</Button>
</form>
)}
</div>
{!adresseGueltig && (
<p className="text-sm text-muted-foreground">
Trag oben eine E-Mail-Adresse ein, dann erscheinen hier die Knöpfe fürs
Mailprogramm. Kopieren geht auch ohne.
</p>
)}
{adresseGueltig && !mailto && (
<p className="text-sm text-muted-foreground">
Der Text ist zu lang für einen mailto-Link — manche Mailprogramme würden ihn
stillschweigend abschneiden. Nimm die .eml-Datei, sie trägt den vollen Text.
</p>
)}
</div>
)
}
return (
<div className="space-y-6">
{warnungen.length > 0 && (
<Alert variant="warnung">
<AlertCircle aria-hidden="true" />
<AlertDescription>
<p className="font-medium">Vor dem Abschicken klären:</p>
<ul className="mt-1 list-disc space-y-1 pl-4">
{warnungen.map((warnung) => (
<li key={warnung}>{warnung}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{/* --- Empfänger ---------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">An wen geht das Schreiben?</CardTitle>
<CardDescription>
Der Empfänger steht im Brieftext darunter — was du hier einträgst, landet am Dokument
in der Akte und im Dateinamen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{kontakte.length > 0 && (
<Feld
id="kontaktAuswahl"
beschriftung="Aus den Kontakten übernehmen"
erklaerung="Spart das Abtippen, wenn die Stelle schon im System steht."
>
<Select
id="kontaktAuswahl"
defaultValue=""
onChange={(ereignis) => kontaktUebernehmen(ereignis.target.value)}
>
<option value="">– Kontakt wählen –</option>
{kontakte.map((kontakt) => (
<option key={kontakt.id} value={kontakt.id}>
{kontakt.organisation ?? kontakt.name}
{kontakt.ort ? ` · ${kontakt.ort}` : ''}
</option>
))}
</Select>
</Feld>
)}
<Feld id="empfaengerName" beschriftung="Name oder Gesellschaft" pflicht>
<Input
id="empfaengerName"
value={empfaengerName}
onChange={(ereignis) => setEmpfaengerName(ereignis.target.value)}
maxLength={200}
placeholder="z. B. Allianz Lebensversicherungs-AG"
/>
</Feld>
<Feld
id="empfaengerAnschrift"
beschriftung="Anschrift"
erklaerung="Straße, dann PLZ und Ort — je Zeile eine Angabe. Nur nötig, wenn du daraus einen Kontakt anlegen willst; in den Brieftext trägst du sie unten selbst ein."
>
<Textarea
id="empfaengerAnschrift"
value={empfaengerAnschrift}
onChange={(ereignis) => setEmpfaengerAnschrift(ereignis.target.value)}
rows={3}
maxLength={500}
/>
</Feld>
<Feld
id="empfaengerEmail"
beschriftung="E-Mail-Adresse"
erklaerung="Nur nötig, wenn das Schreiben als E-Mail hinausgehen soll. Wird aus dem gewählten Kontakt übernommen."
>
<Input
id="empfaengerEmail"
type="email"
value={empfaengerEmail}
onChange={(ereignis) => setEmpfaengerEmail(ereignis.target.value)}
maxLength={200}
placeholder="service@versicherung.de"
/>
</Feld>
{empfaengerName.trim() !== '' && empfaengerAnschrift.trim() !== '' && (
<form action={kontaktAnlegen}>
<VersteckteFelder />
{kontaktZustand.fehler && (
<Alert variant="fehler" className="mb-2">
<AlertCircle aria-hidden="true" />
<AlertDescription>{kontaktZustand.fehler}</AlertDescription>
</Alert>
)}
{kontaktZustand.erfolg && (
<Alert variant="erfolg" className="mb-2">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{kontaktZustand.erfolg}</AlertDescription>
</Alert>
)}
<Absendeknopf variant="outline" size="sm" wartetext="Wird angelegt …">
<UserPlus aria-hidden="true" />
Als Kontakt merken
</Absendeknopf>
</form>
)}
</CardContent>
</Card>
{/* --- Der Brief ---------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Der Brief</CardTitle>
<CardDescription>
Vollständig bearbeitbar. Was hier steht, steht auch in der Word-Datei und in der
Fassung für die Akte.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Feld
id="betreff"
beschriftung="Betreff"
erklaerung="Wird zum Titel des Dokuments in der Akte."
pflicht
>
<Input
id="betreff"
value={betreff}
onChange={(ereignis) => setBetreff(ereignis.target.value)}
maxLength={200}
/>
</Feld>
<Feld id="text" beschriftung="Brieftext" pflicht>
<Textarea
id="text"
value={text}
onChange={(ereignis) => setText(ereignis.target.value)}
rows={26}
className="font-mono text-sm leading-relaxed"
spellCheck
/>
</Feld>
<Button type="button" variant="ghost" size="sm" onClick={() => void textKopieren()}>
<Copy aria-hidden="true" />
{kopiert ? 'Kopiert' : 'Text kopieren'}
</Button>
</CardContent>
</Card>
{/* --- Ausgabe ------------------------------------------------------ */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Und jetzt?</CardTitle>
<CardDescription>
Wie geht es hinaus? Die Wahl bestimmt auch, wie das Schreiben in der Akte
gekennzeichnet wird.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex gap-2" role="group" aria-label="Versandweg">
<Button
type="button"
variant={versandweg === 'BRIEF' ? 'default' : 'outline'}
onClick={() => setVersandweg('BRIEF')}
>
<FileDown aria-hidden="true" />
Als Brief
</Button>
<Button
type="button"
variant={versandweg === 'EMAIL' ? 'default' : 'outline'}
onClick={() => setVersandweg('EMAIL')}
>
<Mail aria-hidden="true" />
Als E-Mail
</Button>
</div>
{versandweg === 'EMAIL' && <EmailAusgabe />}
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="versandAm"
beschriftung="Versandt am"
erklaerung="Steht am Dokument in der Akte."
>
<Input
id="versandAm"
type="date"
value={versandAm}
onChange={(ereignis) => setVersandAm(ereignis.target.value)}
/>
</Feld>
<Feld
id="wiedervorlageAm"
beschriftung="Wiedervorlage am"
erklaerung={fristBegruendung ?? 'Leer lassen, wenn du keine Antwort erwartest.'}
>
<Input
id="wiedervorlageAm"
type="date"
value={wiedervorlageAm}
onChange={(ereignis) => setWiedervorlageAm(ereignis.target.value)}
/>
</Feld>
</div>
{versandweg === 'BRIEF' && (
<>
{/* Word-Datei: eigene Route, damit der Browser einen echten
Download bekommt. */}
<form action="/schreiben/docx" method="post" className="inline">
<VersteckteFelder />
<Button type="submit" variant="outline">
<FileDown aria-hidden="true" />
Als Word-Datei
</Button>
</form>
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertDescription>
In die Akte kommt eine <strong>PDF-Fassung</strong> dieses Textes — sie ist der
Nachweis, was hinausgegangen ist, und lässt sich nicht mehr ändern. Die
Word-Datei ist die Arbeitsfassung zum Ausdrucken und Unterschreiben.{' '}
<strong>Änderst du sie danach noch in Word</strong>, weicht die Akte davon ab —
dann lieber erst dort fertig schreiben, den Text hierher zurückkopieren und dann
ablegen.
</AlertDescription>
</Alert>
</>
)}
{ablageZustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{ablageZustand.fehler}</AlertDescription>
</Alert>
)}
{abgelegt && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>
<p className="font-medium">{ablageZustand.erfolg}</p>
{ablageZustand.meldung && (
<ul className="mt-1 list-disc space-y-1 pl-4">
{ablageZustand.meldung.map((zeile) => (
<li key={zeile}>{zeile}</li>
))}
</ul>
)}
<p className="mt-2">
<Link href="/dokumente?richtung=AUSGANG" className="underline">
Zu den ausgehenden Dokumenten
</Link>
</p>
</AlertDescription>
</Alert>
)}
<form action={ablegen}>
<VersteckteFelder />
<Absendeknopf disabled={abgelegt} wartetext="Wird abgelegt …">
<FolderInput aria-hidden="true" />
{abgelegt ? 'Liegt in der Akte' : 'In die Akte legen'}
</Absendeknopf>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -1,7 +1,8 @@
'use client'
import Link from 'next/link'
import { useActionState } from 'react'
import { CheckCircle2, FileWarning, Gavel, Scale, TriangleAlert } from 'lucide-react'
import { CheckCircle2, FileDown, FileWarning, Gavel, Scale, TriangleAlert } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
@@ -10,6 +11,7 @@ import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Button } from '@/components/ui/button'
import { Absendeknopf, Textbaustein, Zustandsmeldung } from '@/components/substanz-formular'
import { kuendigungVermerken } from '@/server/actions/vertraege'
import { formatDatum } from '@/lib/utils'
@@ -161,6 +163,15 @@ export function KuendigungsAssistent({
beschriftung="Kündigungsschreiben"
hoehe="min-h-[36rem]"
/>
<div className="mt-4">
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=KUENDIGUNG&vertragId=${vertragId}`}>
<FileDown aria-hidden="true" />
Bearbeiten, als Word-Datei und in die Akte
</Link>
</Button>
</div>
</CardContent>
</Card>
+11 -1
View File
@@ -1,7 +1,8 @@
'use client'
import Link from 'next/link'
import { useActionState, useState } from 'react'
import { Coins, Mail, Plus } from 'lucide-react'
import { Coins, FileDown, Mail, Plus } 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'
@@ -149,6 +150,15 @@ export function Rueckkaufswert({
hoehe="min-h-[32rem]"
/>
{/* Der Weg, der nicht beim Kopieren endet: bearbeiten,
Word-Datei, Ablage in der Akte, Wiedervorlage. */}
<Button asChild variant="outline">
<Link href={`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`}>
<FileDown aria-hidden="true" />
Bearbeiten, als Word-Datei und in die Akte
</Link>
</Button>
<form action={anfrageAktion} className="space-y-4" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vertragId" value={vertragId} />
+464
View File
@@ -0,0 +1,464 @@
import 'server-only'
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server'
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/server'
import type { Passkey, PasskeyAnfrageArt, User } from '@prisma/client'
import { db } from '@/lib/db'
import { env } from '@/lib/env'
/**
* Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg.
*
* ---------------------------------------------------------------------------
* Warum ein Passkey hier den zweiten Faktor ERSETZEN darf
* ---------------------------------------------------------------------------
*
* Die Zusage dieser Anwendung lautet: zwei Faktoren, nicht abschaltbar. Ein
* Passkey sieht auf den ersten Blick nach einem einzigen Klick aus - er ist
* aber zwei Faktoren, sofern der Authenticator die Nutzerverifikation
* durchfuehrt:
*
* 1. Besitz - der private Schluessel liegt im Sicherheitsbaustein des
* Geraets und laesst sich nicht auslesen.
* 2. Wissen - Geraete-PIN
* oder Sein - Fingerabdruck, Gesicht.
*
* Diese Bedingung wird nicht gehofft, sondern erzwungen: bei der Anmeldung
* ebenso wie beim Einrichten steht userVerification auf "required", und die
* Pruefung laeuft mit requireUserVerification. Ein Authenticator, der nur
* Anwesenheit meldet (blosses Antippen), wird abgewiesen.
*
* Zusaetzlich ist ein Passkey dem Zahlenkomma aus Passwort + TOTP in einem
* Punkt ueberlegen: Er ist an die Domain gebunden. Eine nachgebaute
* Anmeldeseite bekommt keine Signatur - Phishing, der haeufigste Weg an
* einen TOTP-Code zu kommen, laeuft ins Leere.
*
* ---------------------------------------------------------------------------
* Warum TOTP trotzdem zuerst eingerichtet sein muss
* ---------------------------------------------------------------------------
*
* Ein Passkey haengt am Geraet. Geht das Handy verloren und ist es der
* einzige Zugangsweg, ist die Akte zu - und in ihr stehen Unterlagen, die
* eine betreute Person braucht. Deshalb laesst sich ein Passkey erst
* anlegen, wenn der zweite Faktor samt Wiederherstellungscodes steht. Der
* Passkey ist die bequeme Tuer, nicht die einzige.
*/
/**
* Wie lange eine begonnene Passkey-Aktion abgeschlossen werden kann.
*
* Fuenf Minuten sind reichlich fuer "Handy suchen, Finger auflegen" und
* kurz genug, dass eine abgefangene Challenge nichts mehr wert ist.
*/
export const ANFRAGE_GUELTIG_SEKUNDEN = 300
/**
* Obergrenze je Benutzer. Nicht aus technischen Gruenden - die Liste soll
* uebersichtlich bleiben, damit ein fremder Eintrag auffaellt.
*/
export const MAX_PASSKEYS_JE_BENUTZER = 10
/**
* Obergrenze fuer gleichzeitig offene Anmeldeversuche.
*
* Eine Anmeldung darf jeder beginnen, auch ohne angemeldet zu sein - anders
* ginge es nicht. Damit daraus kein Weg wird, die Tabelle vollzuschreiben,
* gilt eine Decke. Fuer eine Handvoll Benutzer ist sie unerreichbar; fuer
* ein Skript ist sie schnell erreicht, und dann ist Schluss.
*/
export const MAX_OFFENE_ANMELDUNGEN = 200
export type PasskeyGrund =
| 'nicht-verfuegbar'
| 'anfrage-abgelaufen'
| 'unbekannt'
| 'nicht-verifiziert'
| 'geklont'
| 'bereits-vorhanden'
| 'zu-viele'
| 'zweiter-faktor-fehlt'
| 'zu-viele-anfragen'
| 'ungueltig'
/** Traegt einen Grund, damit die Oberflaeche einen brauchbaren Satz daraus macht. */
export class PasskeyFehler extends Error {
constructor(
readonly grund: PasskeyGrund,
nachricht?: string,
) {
super(nachricht ?? grund)
this.name = 'PasskeyFehler'
}
}
export const PASSKEY_MELDUNG: Record<PasskeyGrund, string> = {
'nicht-verfuegbar':
'Passkeys brauchen eine verschlüsselte Verbindung. Diese Installation läuft nicht über https — ' +
'melde dich mit Passwort und Code an.',
'anfrage-abgelaufen':
'Das hat zu lange gedauert. Bitte klicke noch einmal auf „Mit Passkey anmelden“.',
unbekannt:
'Dieser Passkey ist hier nicht hinterlegt. Vielleicht gehört er zu einer anderen Adresse ' +
'oder wurde inzwischen entfernt.',
'nicht-verifiziert':
'Dein Gerät hat den Passkey ohne Fingerabdruck, Gesichtserkennung oder PIN freigegeben. ' +
'Das wäre nur ein Faktor — und hier liegen Gesundheitsdaten. Bitte richte die Gerätesperre ein.',
geklont:
'Der Zähler dieses Passkeys ist nicht weitergelaufen. Das kann auf eine Kopie hindeuten. ' +
'Der Passkey wurde nicht akzeptiert — bitte entferne ihn und richte einen neuen ein.',
'bereits-vorhanden': 'Dieser Passkey ist bereits hinterlegt.',
'zu-viele': `Es sind höchstens ${MAX_PASSKEYS_JE_BENUTZER} Passkeys möglich. Entferne zuerst einen alten.`,
'zweiter-faktor-fehlt':
'Richte zuerst die Authenticator-App ein. Ein Passkey hängt an einem Gerät — ohne einen ' +
'zweiten Weg wäre der Zugang bei Verlust zu.',
'zu-viele-anfragen':
'Gerade laufen ungewöhnlich viele Anmeldeversuche. Bitte versuche es in ein paar Minuten ' +
'noch einmal oder melde dich mit Passwort und Code an.',
ungueltig: 'Der Passkey konnte nicht geprüft werden. Bitte versuche es noch einmal.',
}
/**
* Kopiert Bytes in ein Uint8Array mit eigenem, nicht geteiltem Puffer.
*
* Prisma liefert einen Buffer, TextEncoder ein Uint8Array<ArrayBufferLike> -
* die Bibliothek verlangt ausdruecklich Uint8Array<ArrayBuffer>. Der
* Unterschied ist nicht kosmetisch: ArrayBufferLike schliesst
* SharedArrayBuffer ein, und auf geteiltem Speicher darf keine
* Signaturpruefung arbeiten, weil sich der Inhalt waehrenddessen aendern
* koennte.
*/
function alsUint8(daten: Uint8Array): Uint8Array<ArrayBuffer> {
const kopie = new Uint8Array(new ArrayBuffer(daten.byteLength))
kopie.set(daten)
return kopie
}
/**
* Holt die Challenge aus der Antwort des Browsers.
*
* Sie steckt in den clientData, die der Authenticator mitsigniert hat. Wir
* lesen sie hier nur, um die passende Zeile zu finden - geprueft wird sie
* anschliessend von der Bibliothek gegen genau diesen Wert.
*
* Exportiert, damit sie sich ohne Datenbank pruefen laesst.
*/
export function challengeAusAntwort(clientDataJSON: string): string {
try {
const roh = Buffer.from(clientDataJSON, 'base64url').toString('utf8')
const gelesen: unknown = JSON.parse(roh)
const challenge =
typeof gelesen === 'object' && gelesen !== null && 'challenge' in gelesen
? (gelesen as { challenge: unknown }).challenge
: null
if (typeof challenge !== 'string' || challenge.length === 0) {
throw new PasskeyFehler('ungueltig')
}
return challenge
} catch (fehler) {
if (fehler instanceof PasskeyFehler) throw fehler
throw new PasskeyFehler('ungueltig')
}
}
/** Legt eine Challenge ab. Nur der Server kennt sie, bis der Browser antwortet. */
async function anfrageMerken(
challenge: string,
art: PasskeyAnfrageArt,
userId: string | null,
): Promise<void> {
await db.passkeyAnfrage.create({
data: {
challenge,
art,
userId,
ablaufAm: new Date(Date.now() + ANFRAGE_GUELTIG_SEKUNDEN * 1000),
},
})
}
/**
* Verbraucht eine Challenge - und zwar genau einmal.
*
* Das DELETE ist der eigentliche Schutz gegen das erneute Einspielen einer
* mitgeschnittenen Antwort: Die Datenbank entscheidet, wer die Zeile
* bekommt. Wurde keine Zeile geloescht, war sie schon verbraucht oder
* abgelaufen - beides fuehrt zum Abbruch.
*/
async function anfrageVerbrauchen(
challenge: string,
art: PasskeyAnfrageArt,
userId?: string,
): Promise<void> {
const { count } = await db.passkeyAnfrage.deleteMany({
where: {
challenge,
art,
ablaufAm: { gt: new Date() },
...(userId ? { userId } : {}),
},
})
if (count !== 1) throw new PasskeyFehler('anfrage-abgelaufen')
}
/** Wirft, wenn die Installation gar nicht ueber https erreichbar ist. */
function verfuegbarkeitPruefen(): void {
if (!env.passkey.sicherAufrufbar) throw new PasskeyFehler('nicht-verfuegbar')
}
/** Ob Passkeys unter der aktuellen Adresse ueberhaupt angeboten werden koennen. */
export function passkeysMoeglich(): boolean {
return env.passkey.sicherAufrufbar
}
// ---------------------------------------------------------------------------
// Einrichten
// ---------------------------------------------------------------------------
export async function registrierungVorbereiten(
benutzer: User,
): Promise<PublicKeyCredentialCreationOptionsJSON> {
verfuegbarkeitPruefen()
// Ohne Authenticator-App kein Passkey - siehe Kopfkommentar.
if (!benutzer.totpAktiviertAm) throw new PasskeyFehler('zweiter-faktor-fehlt')
const vorhandene = await db.passkey.findMany({
where: { userId: benutzer.id },
select: { credentialId: true, transports: true },
})
if (vorhandene.length >= MAX_PASSKEYS_JE_BENUTZER) throw new PasskeyFehler('zu-viele')
const optionen = await generateRegistrationOptions({
rpName: env.passkey.rpName,
rpID: env.passkey.rpId,
// Stabil aus der Benutzer-ID. Damit erkennt ein Geraet beim zweiten
// Einrichten, dass es derselbe Zugang ist, und ersetzt den Eintrag,
// statt einen zweiten anzulegen.
userID: alsUint8(new TextEncoder().encode(benutzer.id)),
userName: benutzer.email,
userDisplayName: benutzer.name,
// Keine Attestierung anfordern: Wir wollen nicht wissen, welches
// Fabrikat der Benutzer benutzt. Das ist ein Datum, das uns nichts
// angeht, und es macht den Ablauf auf manchen Geraeten umstaendlicher.
attestationType: 'none',
// Schon hinterlegte Schluessel ausschliessen, sonst legt dasselbe
// Geraet stillschweigend einen zweiten an.
excludeCredentials: vorhandene.map((eintrag) => ({
id: eintrag.credentialId,
transports: eintrag.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
// "required": der Schluessel wird auf dem Geraet auffindbar
// gespeichert. Nur dann geht die Anmeldung ohne vorherige Eingabe
// der E-Mail-Adresse.
residentKey: 'required',
requireResidentKey: true,
// Der Punkt, an dem aus einem Faktor zwei werden.
userVerification: 'required',
},
timeout: ANFRAGE_GUELTIG_SEKUNDEN * 1000,
})
await anfrageMerken(optionen.challenge, 'REGISTRIERUNG', benutzer.id)
return optionen
}
export async function registrierungPruefen(
benutzer: User,
name: string,
antwort: RegistrationResponseJSON,
): Promise<Passkey> {
verfuegbarkeitPruefen()
if (!benutzer.totpAktiviertAm) throw new PasskeyFehler('zweiter-faktor-fehlt')
const challenge = challengeAusAntwort(antwort.response.clientDataJSON)
await anfrageVerbrauchen(challenge, 'REGISTRIERUNG', benutzer.id)
let ergebnis
try {
ergebnis = await verifyRegistrationResponse({
response: antwort,
expectedChallenge: challenge,
expectedOrigin: env.passkey.herkunft,
expectedRPID: env.passkey.rpId,
requireUserVerification: true,
})
} catch {
throw new PasskeyFehler('ungueltig')
}
if (!ergebnis.verified) throw new PasskeyFehler('ungueltig')
const { credential, credentialDeviceType, credentialBackedUp, userVerified } =
ergebnis.registrationInfo
// Doppelte Absicherung: die Bibliothek prueft es bereits, aber diese
// Zusage ist zu wichtig, um sie an einer Voreinstellung haengen zu lassen.
if (!userVerified) throw new PasskeyFehler('nicht-verifiziert')
const schonDa = await db.passkey.findUnique({
where: { credentialId: credential.id },
select: { id: true },
})
if (schonDa) throw new PasskeyFehler('bereits-vorhanden')
return db.passkey.create({
data: {
userId: benutzer.id,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: BigInt(credential.counter),
transports: credential.transports ?? [],
name,
geraeteArt: credentialDeviceType,
gesichert: credentialBackedUp,
},
})
}
// ---------------------------------------------------------------------------
// Anmelden
// ---------------------------------------------------------------------------
export async function anmeldungVorbereiten(): Promise<PublicKeyCredentialRequestOptionsJSON> {
verfuegbarkeitPruefen()
// Erst aufraeumen, dann die Decke pruefen: Abgelaufenes soll nicht als
// laufender Versuch zaehlen.
await abgelaufeneAnfragenEntfernen()
const offen = await db.passkeyAnfrage.count({ where: { art: 'ANMELDUNG' } })
if (offen >= MAX_OFFENE_ANMELDUNGEN) throw new PasskeyFehler('zu-viele-anfragen')
const optionen = await generateAuthenticationOptions({
rpID: env.passkey.rpId,
// Bewusst ohne allowCredentials: Der Server soll vor der Anmeldung
// nicht verraten, welche Schluessel es zu einer Adresse gibt - und der
// Benutzer soll seine E-Mail gar nicht erst eintippen muessen. Das
// Geraet weiss selbst, welchen Schluessel es fuer diese Domain hat.
userVerification: 'required',
timeout: ANFRAGE_GUELTIG_SEKUNDEN * 1000,
})
await anfrageMerken(optionen.challenge, 'ANMELDUNG', null)
return optionen
}
export type PasskeyAnmeldung = {
benutzer: User
passkey: Passkey
}
/**
* Prueft eine Anmeldeantwort vollstaendig und meldet, wem sie gehoert.
*
* Alles, was hier durchgeht, ist kryptografisch belegt: die Signatur passt
* zum hinterlegten oeffentlichen Schluessel, die Challenge war frisch und
* genau einmal gueltig, die Domain stimmt, und das Geraet hat den Benutzer
* verifiziert.
*
* Rate-Limiting und Audit-Log passieren NICHT hier, sondern beim Aufrufer -
* dort ist bekannt, ob es eine Anmeldung oder eine erneute Bestaetigung ist.
*/
export async function anmeldungPruefen(
antwort: AuthenticationResponseJSON,
): Promise<PasskeyAnmeldung> {
verfuegbarkeitPruefen()
const challenge = challengeAusAntwort(antwort.response.clientDataJSON)
await anfrageVerbrauchen(challenge, 'ANMELDUNG')
const passkey = await db.passkey.findUnique({
where: { credentialId: antwort.id },
include: { user: true },
})
if (!passkey) throw new PasskeyFehler('unbekannt')
let ergebnis
try {
ergebnis = await verifyAuthenticationResponse({
response: antwort,
expectedChallenge: challenge,
expectedOrigin: env.passkey.herkunft,
expectedRPID: env.passkey.rpId,
requireUserVerification: true,
credential: {
id: passkey.credentialId,
publicKey: alsUint8(passkey.publicKey),
counter: Number(passkey.counter),
transports: passkey.transports as AuthenticatorTransportFuture[],
},
})
} catch (fehler) {
// Die Bibliothek meldet einen nicht weitergelaufenen Zaehler als
// gewoehnlichen Fehler. Der verdient eine eigene Meldung: er ist der
// einzige Hinweis auf einen kopierten Schluessel, den wir bekommen.
const text = fehler instanceof Error ? fehler.message : ''
if (text.includes('counter value')) throw new PasskeyFehler('geklont')
throw new PasskeyFehler('ungueltig')
}
if (!ergebnis.verified) throw new PasskeyFehler('ungueltig')
if (!ergebnis.authenticationInfo.userVerified) throw new PasskeyFehler('nicht-verifiziert')
await db.passkey.update({
where: { id: passkey.id },
data: {
counter: BigInt(ergebnis.authenticationInfo.newCounter),
zuletztBenutztAm: new Date(),
// Beides kann sich nachtraeglich aendern, etwa wenn der Benutzer die
// Schluesselbund-Synchronisierung einschaltet.
geraeteArt: ergebnis.authenticationInfo.credentialDeviceType,
gesichert: ergebnis.authenticationInfo.credentialBackedUp,
},
})
return { benutzer: passkey.user, passkey }
}
// ---------------------------------------------------------------------------
// Verwalten und Aufraeumen
// ---------------------------------------------------------------------------
export type PasskeyUebersicht = {
id: string
name: string
geraeteArt: string | null
gesichert: boolean
erstelltAm: Date
zuletztBenutztAm: Date | null
}
export async function passkeysDesBenutzers(userId: string): Promise<PasskeyUebersicht[]> {
return db.passkey.findMany({
where: { userId },
orderBy: [{ zuletztBenutztAm: { sort: 'desc', nulls: 'last' } }, { erstelltAm: 'desc' }],
select: {
id: true,
name: true,
geraeteArt: true,
gesichert: true,
erstelltAm: true,
zuletztBenutztAm: true,
},
})
}
/** Raeumt liegengebliebene Challenges weg; wird vom Wartungslauf aufgerufen. */
export async function abgelaufeneAnfragenEntfernen(): Promise<number> {
const { count } = await db.passkeyAnfrage.deleteMany({
where: { ablaufAm: { lt: new Date() } },
})
return count
}
+59 -24
View File
@@ -1,4 +1,5 @@
import 'server-only'
import type { Prisma } from '@prisma/client'
import type { Betreute, User } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
@@ -85,6 +86,62 @@ export function berichtskopf(eingabe: {
// Laden
// -----------------------------------------------------------------------------
/**
* Was eine Buchung mitbringen muss, damit sich daraus eine BuchungEingabe
* bauen laesst.
*
* Steht hier und nicht in jeder Seite einzeln: Die Buchungsliste hatte
* lange eine eigene, fast gleiche Abfrage. Als die Dauerzahlungen
* dazukamen, wurde nur eine der beiden nachgezogen - und in der Liste
* stand weiter "Beleg fehlt", obwohl der Vertrag hinterlegt war. Ein
* gemeinsamer Einbezug kann nicht halb vergessen werden.
*/
export const BUCHUNG_EINBEZUG = {
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
beleg: { select: { id: true, titel: true } },
// Bei einer Dauerzahlung ist der Vertrag der Beleg, und sein Beitrag ist
// der Massstab fuer die Betragspruefung.
vertrag: {
select: {
id: true,
bezeichnung: true,
beitragCent: true,
rhythmus: true,
vertragsnummerVerschluesselt: true,
},
},
} as const
type BuchungMitBezug = Prisma.BuchungGetPayload<{ include: typeof BUCHUNG_EINBEZUG }>
/** Wandelt eine geladene Zeile in die Form um, mit der die Auswertung rechnet. */
export function zuBuchungEingabe(zeile: BuchungMitBezug): BuchungEingabe {
const offen = oeffnen('Buchung', zeile)
return {
id: zeile.id,
datum: zeile.datum,
betragCent: zeile.betragCent,
richtung: zeile.richtung,
kategorie: zeile.kategorie,
gegenstelle: zeile.gegenstelle,
verwendungszweck: offen.verwendungszweck,
kontoId: zeile.kontoId,
kontoBezeichnung: zeile.konto?.bezeichnung ?? null,
belegId: zeile.belegId,
belegTitel: zeile.beleg?.titel ?? null,
belegFehltBegruendung: zeile.belegFehltBegruendung,
vertragId: zeile.vertragId,
vertragBezeichnung: zeile.vertrag?.bezeichnung ?? null,
vertragsnummer: zeile.vertrag ? oeffnen('Vertrag', zeile.vertrag).vertragsnummer : null,
vertragBeitragCent: zeile.vertrag?.beitragCent ?? null,
vertragRhythmus: zeile.vertrag?.rhythmus ?? null,
vertragAutomatisch: zeile.vertragAutomatisch,
betrifftBetreute: zeile.betrifftBetreute,
vermoegenswirksam: zeile.vermoegenswirksam,
aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false,
}
}
/**
* Alle Buchungen einer betreuten Person, entschluesselt und um die
* Angaben ergaenzt, die die Auswertung braucht (Kontoname, Belegtitel,
@@ -102,33 +159,11 @@ export async function buchungenLaden(
? { datum: { gte: alsTag(zeitraum.von), lte: alsTag(zeitraum.bis) } }
: {}),
},
include: {
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
beleg: { select: { id: true, titel: true } },
},
include: BUCHUNG_EINBEZUG,
orderBy: [{ datum: 'asc' }, { erstelltAm: 'asc' }],
})
return zeilen.map((zeile) => {
const offen = oeffnen('Buchung', zeile)
return {
id: zeile.id,
datum: zeile.datum,
betragCent: zeile.betragCent,
richtung: zeile.richtung,
kategorie: zeile.kategorie,
gegenstelle: zeile.gegenstelle,
verwendungszweck: offen.verwendungszweck,
kontoId: zeile.kontoId,
kontoBezeichnung: zeile.konto?.bezeichnung ?? null,
belegId: zeile.belegId,
belegTitel: zeile.beleg?.titel ?? null,
belegFehltBegruendung: zeile.belegFehltBegruendung,
betrifftBetreute: zeile.betrifftBetreute,
vermoegenswirksam: zeile.vermoegenswirksam,
aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false,
}
})
return zeilen.map(zuBuchungEingabe)
}
/** Konten mit ihrer vollstaendigen Saldostand-Historie. */
+9
View File
@@ -444,6 +444,15 @@ export function RechnungslegungDokument({
<View style={stile.mittel}>
{beleg.belegTitel ? (
<Text style={stile.klein}>{beleg.belegTitel}</Text>
) : beleg.vertragBezeichnung ? (
<Text style={stile.klein}>
Dauerzahlung zu Vertrag „{beleg.vertragBezeichnung}“
{beleg.vertragsnummer ? `, Nr. ${beleg.vertragsnummer}` : ''}
</Text>
) : beleg.betragZuPruefen ? (
<Text style={[stile.klein, stile.fett]}>
Beleg fehlt – Betrag weicht vom Vertrag ab
</Text>
) : beleg.belegFehltBegruendung ? (
<Text style={stile.klein}>
ohne Beleg – {beleg.belegFehltBegruendung}
+29 -2
View File
@@ -21,6 +21,7 @@ import type {
PosteingangDaten,
WartetAufAndereDaten,
} from '@/lib/dashboard/typen'
import { offeneZahlungsgruppen } from '@/lib/finanzen/wiederkehrend-laden'
/**
* Alles, was das Dashboard "Heute" braucht - in einem Rutsch.
@@ -166,7 +167,17 @@ export async function dashboardLaden(
// 5b. Buchungen ohne Beleg und ohne Begruendung - Belegzwang.
darfFinanzen
? db.buchung.findMany({
where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht },
where: {
betreuteId,
belegId: null,
belegFehltBegruendung: null,
// Dauerzahlungen sind durch ihren Vertrag belegt - sie gehoeren
// nicht in die Liste der offenen Belege. Die Betragspruefung
// laeuft erst spaeter auf den geladenen Zeilen; hier wird nur
// grob vorgefiltert.
vertragId: null,
...nichtGeloescht,
},
orderBy: { datum: 'desc' },
take: KACHEL_ZEILEN,
select: {
@@ -181,7 +192,17 @@ export async function dashboardLaden(
: null,
darfFinanzen
? db.buchung.count({
where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht },
where: {
betreuteId,
belegId: null,
belegFehltBegruendung: null,
// Dauerzahlungen sind durch ihren Vertrag belegt - sie gehoeren
// nicht in die Liste der offenen Belege. Die Betragspruefung
// laeuft erst spaeter auf den geladenen Zeilen; hier wird nur
// grob vorgefiltert.
vertragId: null,
...nichtGeloescht,
},
})
: null,
@@ -267,6 +288,11 @@ export async function dashboardLaden(
}
: null
// Regelmaessige Abbuchungen ohne Vertrag. Eigene Abfrage, weil sie andere
// Spalten braucht als der Belegzwang - und weil sie ganz entfaellt, wenn
// das Finanzmodul nicht lesbar ist.
const wiederkehrendOhneVertrag = darfFinanzen ? await offeneZahlungsgruppen(betreuteId) : 0
const belege: BelegeDaten | null =
buchungenOhneBeleg && buchungenOhneBelegGesamt !== null
? {
@@ -286,6 +312,7 @@ export async function dashboardLaden(
gegenstelle: b.gegenstelle,
})),
buchungenOhneBelegGesamt,
wiederkehrendOhneVertrag,
}
: null
+9
View File
@@ -149,6 +149,15 @@ export type BelegeDaten = {
belegeOhneBuchungGesamt: number
buchungenOhneBeleg: BuchungOhneBeleg[]
buchungenOhneBelegGesamt: number
/**
* Regelmaessige Abbuchungen ohne hinterlegten Vertrag.
*
* Steht bewusst in dieser Kachel und nicht in einer eigenen: Es ist
* dieselbe Frage - "wozu gehoert diese Zahlung?" -, nur eine Ebene
* hoeher. Eine sechste Kachel auf "Heute" waere eine Kachel, die man
* nach zwei Wochen nicht mehr sieht.
*/
wiederkehrendOhneVertrag: number
}
// -----------------------------------------------------------------------------
+29
View File
@@ -185,6 +185,35 @@ export const env = {
return (process.env.AUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
},
/**
* Herkunft und Bereichskennung fuer Passkeys (WebAuthn).
*
* Beides wird aus AUTH_URL abgeleitet und nicht getrennt konfiguriert -
* eine zweite Quelle waere eine zweite Gelegenheit, sie auseinanderlaufen
* zu lassen.
*
* Die "rpId" ist der nackte Hostname ohne Schema und ohne Port. An sie
* bindet der Authenticator den Schluessel: Ein Passkey fuer
* cockpit.example.de funktioniert auf keiner anderen Adresse - genau das
* macht ihn gegen Phishing immun. Umgekehrt heisst das: Wer die Domain
* wechselt, muss alle Passkeys neu einrichten.
*
* Der Browser gibt WebAuthn ausserdem nur in einem sicheren Kontext frei -
* also unter https oder auf localhost. Hinter einem Proxy, der auf http
* weiterreicht, muss AUTH_URL trotzdem die oeffentliche https-Adresse
* nennen; verglichen wird gegen das, was im Browser in der Adresszeile
* stand.
*/
get passkey() {
const url = new URL(process.env.AUTH_URL ?? 'http://localhost:3000')
return {
rpId: url.hostname,
rpName: 'Betreuungs-Cockpit',
herkunft: url.origin,
sicherAufrufbar: url.protocol === 'https:' || url.hostname === 'localhost',
}
},
get istProduktion() {
return process.env.NODE_ENV === 'production'
},
+126 -5
View File
@@ -1,4 +1,4 @@
import type { BuchungKategorie, BuchungRichtung } from '@prisma/client'
import type { BuchungKategorie, BuchungRichtung, ZahlRhythmus } from '@prisma/client'
import { summe, teileGerundet } from '@/lib/finanzen/betrag'
import { alsTag, imZeitraum, nachTagSortieren } from '@/lib/finanzen/datum'
import {
@@ -39,6 +39,15 @@ export type BuchungEingabe = {
belegId: string | null
belegTitel?: string | null
belegFehltBegruendung: string | null
/** Dauerzahlung zu einem Vertrag - dann ist der Vertrag der Beleg. */
vertragId?: string | null
vertragBezeichnung?: string | null
vertragsnummer?: string | null
/** Hinterlegter Beitrag samt Zahlweise - Grundlage der Betragspruefung. */
vertragBeitragCent?: bigint | null
vertragRhythmus?: ZahlRhythmus | null
/** Wurde die Zuordnung beim Import automatisch gesetzt? */
vertragAutomatisch?: boolean
betrifftBetreute: boolean
vermoegenswirksam: boolean
/** Laeuft die Buchung ueber ein Konto des Betreuers? */
@@ -54,24 +63,134 @@ export function istVermoegensbuchung(buchung: BuchungEingabe): boolean {
// Belegzwang
// -----------------------------------------------------------------------------
/**
* Wie weit darf die Zahlung vom hinterlegten Beitrag abweichen?
*
* Ein Fuenftel. Das ist grosszuegig und mit Absicht: Beitragsanpassungen,
* Versicherungsteuer und ein nachtraeglich geaenderter Selbstbehalt bewegen
* sich in dieser Groessenordnung, und bei jeder davon soll die Anwendung
* nicht meckern.
*
* Was sie fangen soll, ist die andere Groessenordnung: die Praemie, die von
* 42 auf 420 Euro springt, oder die Lastschrift, die versehentlich zweimal
* lief. Wer die Grenze enger zieht, bekommt taeglich Warnungen und sieht
* die eine wichtige nicht mehr.
*/
export const BETRAGS_TOLERANZ_PROZENT = 20n
/**
* Passt der Betrag zu dem, was am Vertrag steht?
*
* Verglichen wird gegen den hinterlegten Beitrag, unveraendert. Kein
* Umrechnen auf andere Zahlweisen.
*
* Der erste Entwurf tat genau das - Jahresbetrag bilden und gegen
* jaehrlich, halbjaehrlich, vierteljaehrlich und monatlich pruefen, damit
* es auch passt, wenn die Bank anders abbucht als der Vertrag es fuehrt.
* Das Ergebnis war ein Toleranzband mit vier Loechern: Bei einem
* Monatsbeitrag von 42,50 EUR liegen 420 EUR innerhalb von 20 Prozent des
* Jahresbetrags von 510 EUR. Ausgerechnet der Fall, vor dem diese Pruefung
* schuetzen soll, waere durchgerutscht.
*
* Bucht die Bank tatsaechlich jaehrlich ab, waehrend am Vertrag "monatlich"
* steht, dann stimmen die Vertragsdaten nicht - und dann soll die
* Buchung als "zu pruefen" auffallen, statt stillschweigend akzeptiert zu
* werden. Die Zahlweise am Vertrag zu berichtigen dauert zehn Sekunden und
* macht die Akte richtiger.
*
* Ohne hinterlegten Beitrag gibt es nichts zu vergleichen - dann lautet die
* Antwort "unbekannt" (null), nicht "in Ordnung". Der Unterschied zaehlt:
* Es soll nicht so aussehen, als waere geprueft worden.
*/
export function betragPasstZumVertrag(
betragCent: bigint,
beitragCent: bigint | null | undefined,
rhythmus: ZahlRhythmus | null | undefined,
): boolean | null {
if (beitragCent === null || beitragCent === undefined || beitragCent <= 0n) return null
// Zu einer Einmalzahlung gibt es keine wiederkehrende Rate, gegen die
// sich vergleichen liesse.
if (!rhythmus || rhythmus === 'EINMALIG') return null
const betrag = betragCent < 0n ? -betragCent : betragCent
const abweichung = betrag > beitragCent ? betrag - beitragCent : beitragCent - betrag
return abweichung * 100n <= beitragCent * BETRAGS_TOLERANZ_PROZENT
}
/**
* Ist die Buchung durch einen Vertrag belegt?
*
* Bei einem Dauerschuldverhaeltnis ist der Vertrag der Beleg - fuer zwoelf
* gleiche Lastschriften gibt es keine zwoelf Quittungen, sondern eine
* Police.
*
* Die Anerkennung faellt weg, sobald der Betrag deutlich abweicht. Sonst
* waere die Zuordnung eine Hintertuer, durch die auch eine verdreifachte
* Praemie unbemerkt als "belegt" durchginge.
*/
export function belegDurchVertrag(buchung: BuchungEingabe): boolean {
if (buchung.belegId) return false
if (!buchung.vertragId) return false
return (
betragPasstZumVertrag(
buchung.betragCent,
buchung.vertragBeitragCent,
buchung.vertragRhythmus,
) !== false
)
}
/**
* Zugeordnet, aber der Betrag passt nicht - das will jemand ansehen.
*
* Kein Fehler und kein Vorwurf: Meistens ist es eine Beitragsanpassung, die
* am Vertrag noch nicht nachgetragen wurde. Genau deshalb steht sie hier
* und nicht wortlos unter "Beleg fehlt".
*/
export function betragPruefen(buchung: BuchungEingabe): boolean {
if (buchung.belegId) return false
if (!buchung.vertragId) return false
return (
betragPasstZumVertrag(
buchung.betragCent,
buchung.vertragBeitragCent,
buchung.vertragRhythmus,
) === false
)
}
/**
* Fehlt der Beleg? Eine Begruendung heilt das Fehlen fuer die Statistik,
* nicht fuer den Aktenordner - aber sie beantwortet die Frage des
* Rechtspflegers "und wo ist der Beleg?" schon vorab.
*/
export function belegFehlt(buchung: BuchungEingabe): boolean {
return !buchung.belegId && !(buchung.belegFehltBegruendung ?? '').trim()
if (buchung.belegId) return false
if (belegDurchVertrag(buchung)) return false
return !(buchung.belegFehltBegruendung ?? '').trim()
}
export function belegBegruendet(buchung: BuchungEingabe): boolean {
return !buchung.belegId && (buchung.belegFehltBegruendung ?? '').trim() !== ''
if (buchung.belegId) return false
if (belegDurchVertrag(buchung)) return false
return (buchung.belegFehltBegruendung ?? '').trim() !== ''
}
export type Belegquote = {
gesamt: number
mitBeleg: number
/**
* Dauerzahlung zu einem Vertrag - eigene Kategorie, NICHT eingerechnet in
* "begruendet". Ein Vertrag belegt, eine Begruendung erklaert nur; das
* zusammenzuwerfen nimmt der Zahl ihre Aussage gegenueber dem
* Rechtspfleger.
*/
durchVertrag: number
begruendet: number
ohneNachweis: number
/** Zugeordnet, aber der Betrag weicht ab. Teilmenge von ohneNachweis. */
zuPruefen: number
/** 0 bis 100, ganzzahlig. Bei null Buchungen: 100 - es fehlt ja nichts. */
quoteProzent: number
}
@@ -79,11 +198,13 @@ export type Belegquote = {
export function belegquote(buchungen: BuchungEingabe[]): Belegquote {
const gesamt = buchungen.length
const mitBeleg = buchungen.filter((b) => Boolean(b.belegId)).length
const durchVertrag = buchungen.filter((b) => belegDurchVertrag(b)).length
const begruendet = buchungen.filter((b) => belegBegruendet(b)).length
const ohneNachweis = buchungen.filter((b) => belegFehlt(b)).length
const zuPruefen = buchungen.filter((b) => betragPruefen(b)).length
const quoteProzent =
gesamt === 0 ? 100 : Math.round(((mitBeleg + begruendet) / gesamt) * 100)
return { gesamt, mitBeleg, begruendet, ohneNachweis, quoteProzent }
gesamt === 0 ? 100 : Math.round(((mitBeleg + durchVertrag + begruendet) / gesamt) * 100)
return { gesamt, mitBeleg, durchVertrag, begruendet, ohneNachweis, zuPruefen, quoteProzent }
}
/**
+15
View File
@@ -3,7 +3,9 @@ import { alsTag, imZeitraum, nachTagSortieren, tagePlus, vorOderGleich } from '@
import type { Betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import {
ausgabenGruppiert,
belegDurchVertrag,
belegFehlt,
betragPruefen,
einnahmenGruppiert,
istVermoegensbuchung,
belegquote,
@@ -158,6 +160,16 @@ export type Belegzeile = {
belegId: string | null
belegTitel: string | null
belegFehltBegruendung: string | null
/**
* Dauerzahlung zu einem Vertrag. Steht im Bericht anstelle von
* "Beleg fehlt" - die Luecke wird damit nicht versteckt, sondern
* erklaert: Wer nachpruefen will, weiss, welches Papier er aus dem
* Ordner holt.
*/
vertragBezeichnung: string | null
vertragsnummer: string | null
/** Zugeordnet, aber der Betrag weicht ab - im Bericht ausgewiesen. */
betragZuPruefen: boolean
fehlt: boolean
}
@@ -179,6 +191,9 @@ export function beleglisteErstellen(buchungen: BuchungEingabe[]): Belegzeile[] {
belegId: buchung.belegId,
belegTitel: buchung.belegTitel ?? null,
belegFehltBegruendung: buchung.belegFehltBegruendung,
vertragBezeichnung: belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null,
vertragsnummer: belegDurchVertrag(buchung) ? (buchung.vertragsnummer ?? null) : null,
betragZuPruefen: betragPruefen(buchung),
fehlt: belegFehlt(buchung),
}))
}
+88
View File
@@ -0,0 +1,88 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
import {
einzelneZahlungen,
gruppieren,
offeneKandidaten,
type Einzelzahlung,
type Zahlungsgruppe,
} from '@/lib/finanzen/wiederkehrend'
/**
* Holt die Daten fuer die Ansicht "Wiederkehrende Zahlungen".
*
* Getrennt von wiederkehrend.ts, damit die Erkennung selbst ohne Datenbank
* bleibt und sich mit Einheitstests festnageln laesst. Hier steht nur, was
* ohne Postgres nicht geht.
*/
export type WiederkehrendBild = {
gruppen: Zahlungsgruppe[]
einzelne: Einzelzahlung[]
/** Wie viele Gruppen noch keinem Vertrag zugeordnet sind. */
offen: number
/** Bereits abgehakte Gruppen - zum Zurueckholen. */
abgehakt: { id: string; schluessel: string; anzeigename: string; begruendung: string | null }[]
/** Wurde ueberhaupt schon etwas importiert? Sonst ist die Seite leer aus gutem Grund. */
ausgabenGesamt: number
}
export async function wiederkehrendLaden(betreuteId: string): Promise<WiederkehrendBild> {
const [buchungen, vertraege, abgehakt] = await Promise.all([
db.buchung.findMany({
where: {
betreuteId,
// Nur echte Ausgaben der betreuten Person. Umbuchungen zwischen
// eigenen Konten sind kein Vertragshinweis - wer monatlich aufs
// Sparbuch legt, hat deswegen keinen Vertrag. Auslagen des
// Betreuers gehoeren ebenfalls nicht hierher.
richtung: 'AUSGABE',
betrifftBetreute: true,
...nichtGeloescht,
},
select: {
id: true,
datum: true,
betragCent: true,
gegenstelle: true,
vertragId: true,
},
orderBy: { datum: 'asc' },
}),
db.vertrag.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
}),
db.zahlungsgruppeGeklaert.findMany({
where: { betreuteId },
select: { id: true, schluessel: true, anzeigename: true, begruendung: true },
orderBy: { anzeigename: 'asc' },
}),
])
const optionen = {
abgehakt: new Set(abgehakt.map((eintrag) => eintrag.schluessel)),
vertraege,
}
const gruppen = gruppieren(buchungen, optionen)
return {
gruppen,
einzelne: einzelneZahlungen(buchungen, optionen),
offen: offeneKandidaten(gruppen),
abgehakt,
ausgabenGesamt: buchungen.length,
}
}
/**
* Nur die Zahl fuer das Dashboard - ohne die vollen Listen aufzubauen.
*
* Laedt dieselben Buchungen, gibt aber nur die Anzahl offener Gruppen
* zurueck. Die Kachel soll die Seite nicht spuerbar verlangsamen.
*/
export async function offeneZahlungsgruppen(betreuteId: string): Promise<number> {
const bild = await wiederkehrendLaden(betreuteId)
return bild.offen
}
+546
View File
@@ -0,0 +1,546 @@
import { summe } from '@/lib/finanzen/betrag'
import { nachTagSortieren, tageZwischen } from '@/lib/finanzen/datum'
/**
* Wiederkehrende Zahlungen aufspueren - ohne Datenbank, damit sich jede
* Entscheidung einzeln nachrechnen laesst.
*
* Der Anlass: Am Anfang einer Betreuung weiss niemand, welche Vertraege es
* gibt. Die Antwort steht aber im Kontoauszug - wer regelmaessig abbucht,
* hat einen Vertrag. Diese Datei macht aus hunderten Buchungszeilen eine
* Arbeitsliste.
*
* Zwei Haltungen liegen allem zugrunde:
*
* 1. **Im Zweifel getrennt lassen.** Zwei Zeilen zu viel sind harmlos - man
* sieht hin und hakt ab. Eine falsch zusammengefasste Gruppe verbirgt
* dagegen einen Vertrag, und der fehlt dann im Vermoegensverzeichnis.
* Deshalb wird nur zusammengefasst, was sicher dasselbe ist.
*
* 2. **Nichts behaupten, was nicht belegt ist.** Die Erkennung nennt immer
* mit, worauf sie sich stuetzt ("4 Zahlungen, etwa alle 3 Monate").
* Eine Einordnung ohne nachvollziehbare Grundlage waere geraten, und
* geratene Zahlen landen sonst im Bericht ans Gericht.
*/
// -----------------------------------------------------------------------------
// Eingabe und Ergebnis
// -----------------------------------------------------------------------------
export type ZahlungEingabe = {
id: string
datum: Date
betragCent: bigint
gegenstelle: string | null
/** Haengt die Buchung schon an einem Vertrag? (Issue #11) */
vertragId?: string | null
}
/** Ein hinterlegter Vertrag - zum Abgleich, ob die Zahlung schon erklaert ist. */
export type VertragAbgleich = {
id: string
bezeichnung: string
gesellschaft: string | null
}
export type Rhythmus =
| 'MONATLICH'
| 'ZWEIMONATLICH'
| 'VIERTELJAEHRLICH'
| 'HALBJAEHRLICH'
| 'JAEHRLICH'
| 'UNREGELMAESSIG'
export const RHYTHMUS_BESCHRIFTUNG: Record<Rhythmus, string> = {
MONATLICH: 'monatlich',
ZWEIMONATLICH: 'zweimonatlich',
VIERTELJAEHRLICH: 'vierteljährlich',
HALBJAEHRLICH: 'halbjährlich',
JAEHRLICH: 'jährlich',
UNREGELMAESSIG: 'unregelmäßig',
}
/** Wie oft der Rhythmus im Jahr zuschlaegt. Fuer die Hochrechnung. */
const ZAHLUNGEN_JE_JAHR: Record<Rhythmus, number> = {
MONATLICH: 12,
ZWEIMONATLICH: 6,
VIERTELJAEHRLICH: 4,
HALBJAEHRLICH: 2,
JAEHRLICH: 1,
UNREGELMAESSIG: 0,
}
export type Zahlungsgruppe = {
/** Normalisierter Name - der Schluessel, unter dem abgehakt wird. */
schluessel: string
/** Die laengste vorgefundene Schreibweise, fuer die Anzeige. */
anzeigename: string
/** Alle vorgefundenen Schreibweisen, falls mehrere zusammengefasst wurden. */
schreibweisen: string[]
anzahl: number
ersteZahlung: Date
letzteZahlung: Date
/** Median der Betraege - unempfindlicher gegen einen Ausreisser als der Schnitt. */
betragCent: bigint
kleinsterBetragCent: bigint
groessterBetragCent: bigint
/** Schwanken die Betraege stark? Dann ist es eher keine feste Praemie. */
betragSchwankt: boolean
rhythmus: Rhythmus
/** Medianabstand in Tagen - die Grundlage der Rhythmuserkennung. */
abstandTage: number | null
/** Hochgerechnet auf ein Jahr. Bei unregelmaessigem Rhythmus: das
* tatsaechlich Gezahlte, nicht hochgerechnet. */
jahresbetragCent: bigint
/** Passt der Name zu einem hinterlegten Vertrag? */
vertragId: string | null
vertragBezeichnung: string | null
buchungIds: string[]
/**
* Wie viele Buchungen der Gruppe haengen bereits an einem Vertrag?
* Grundlage fuer die Sammelzuordnung: Nur der Rest muss noch verknuepft
* werden.
*/
bereitsZugeordnet: number
/** Buchungen der Gruppe ohne Vertragsbezug. */
offeneBuchungIds: string[]
}
// -----------------------------------------------------------------------------
// Namen vergleichbar machen
// -----------------------------------------------------------------------------
/**
* Rechtsformen und Zusaetze, die keinen Unterschied machen.
*
* Bewusst kurz gehalten. Jeder Eintrag hier ist eine Behauptung "das darf
* weg" - und jede falsche Behauptung wirft zwei verschiedene Empfaenger
* zusammen. "Versicherung" steht deshalb NICHT in der Liste: Zwischen
* "Allianz Versicherung" und "Allianz Lebensversicherung" liegt ein
* Unterschied, der im Vermoegensverzeichnis zaehlt.
*/
const RECHTSFORMEN = [
'gmbh & co kg',
'gmbh und co kg',
'gmbh',
'mbh',
'ohg',
'kgaa',
'kg',
'ag',
'se',
'ev',
'vvag',
'ag',
'aoer',
'koer',
]
/** Ersetzt Umlaute, damit "Muenchener" und "Münchener" zusammenfallen. */
function umlauteAufloesen(text: string): string {
return text
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
}
/**
* Macht zwei Schreibweisen desselben Empfaengers vergleichbar.
*
* Was hier zusammenfaellt, wird spaeter in einer Zeile angezeigt. Deshalb
* nur das Unstrittige: Gross- und Kleinschreibung, Satzzeichen, doppelte
* Leerzeichen, Umlaute und die Rechtsform am Ende.
*
* Was ausdruecklich NICHT passiert: Aehnlichkeitssuche ueber Abstandsmasse.
* "Stadtwerke Bamberg" und "Stadtwerke Bayreuth" unterscheiden sich in drei
* Zeichen und sind zwei voellig verschiedene Rechnungssteller.
*/
export function normalisieren(gegenstelle: string | null | undefined): string {
if (!gegenstelle) return ''
let text = umlauteAufloesen(gegenstelle.toLowerCase())
// Satzzeichen zu Leerzeichen, damit "vers.ag" zu "vers ag" wird und die
// Rechtsform anschliessend als eigenes Wort erkennbar ist.
text = text.replace(/[.,;:/\\()[\]{}"'`*+_-]/g, ' ')
text = text.replace(/\s+/g, ' ').trim()
// Rechtsform nur am ENDE entfernen. Mitten im Namen kann dieselbe
// Zeichenfolge etwas anderes sein - "ag" steckt in "Agentur".
let geaendert = true
while (geaendert) {
geaendert = false
for (const form of RECHTSFORMEN) {
if (text.endsWith(` ${form}`)) {
text = text.slice(0, -(form.length + 1)).trim()
geaendert = true
break
}
}
}
return text
}
// -----------------------------------------------------------------------------
// Rhythmus
// -----------------------------------------------------------------------------
function median(werte: number[]): number {
if (werte.length === 0) return 0
const sortiert = [...werte].sort((a, b) => a - b)
const mitte = Math.floor(sortiert.length / 2)
return sortiert.length % 2 === 1
? sortiert[mitte]!
: Math.round((sortiert[mitte - 1]! + sortiert[mitte]!) / 2)
}
function medianBigInt(werte: bigint[]): bigint {
if (werte.length === 0) return 0n
const sortiert = [...werte].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
const mitte = Math.floor(sortiert.length / 2)
return sortiert.length % 2 === 1
? sortiert[mitte]!
: (sortiert[mitte - 1]! + sortiert[mitte]!) / 2n
}
/**
* Ordnet einen Medianabstand einem Rhythmus zu.
*
* Die Spannen sind bewusst weit: Lastschriften kommen am ersten Werktag,
* und der wandert. Zwischen dem 1. Februar und dem 1. Maerz liegen 28 Tage,
* zwischen dem 1. Juli und dem 1. August 31 - beides ist monatlich.
*/
export function rhythmusAusAbstand(tage: number): Rhythmus {
if (tage >= 24 && tage <= 38) return 'MONATLICH'
if (tage >= 50 && tage <= 70) return 'ZWEIMONATLICH'
if (tage >= 80 && tage <= 100) return 'VIERTELJAEHRLICH'
if (tage >= 160 && tage <= 200) return 'HALBJAEHRLICH'
if (tage >= 330 && tage <= 400) return 'JAEHRLICH'
return 'UNREGELMAESSIG'
}
/** Der Medianabstand zwischen aufeinanderfolgenden Zahlungen, in Tagen. */
export function abstandInTagen(daten: Date[]): number | null {
if (daten.length < 2) return null
const sortiert = nachTagSortieren(daten, (d) => d)
const abstaende: number[] = []
for (let i = 1; i < sortiert.length; i++) {
abstaende.push(Math.abs(tageZwischen(sortiert[i - 1]!, sortiert[i]!)))
}
return median(abstaende)
}
/**
* Schwanken die Betraege so stark, dass es keine feste Zahlung mehr ist?
*
* Ein Fuenftel Abweichung vom Median ist grosszuegig - Beitragsanpassungen
* und Stromabschlaege bewegen sich in dieser Groessenordnung. Wer darueber
* liegt, zahlt eher nach Verbrauch als nach Vertrag.
*/
export function betraegeSchwanken(betraege: bigint[]): boolean {
if (betraege.length < 2) return false
const mitte = medianBigInt(betraege)
if (mitte === 0n) return true
return betraege.some((betrag) => {
const abweichung = betrag > mitte ? betrag - mitte : mitte - betrag
// Ganzzahlig gerechnet: |x - m| * 100 > m * 20
return abweichung * 100n > mitte * 20n
})
}
// -----------------------------------------------------------------------------
// Gruppieren
// -----------------------------------------------------------------------------
/**
* Ab wann gilt eine Zahlung als wiederkehrend?
*
* Zwei ist das Minimum, das ueberhaupt einen Abstand ergibt. Damit bleibt
* eine Luecke, die man kennen muss: Eine JAEHRLICH abgebuchte Praemie
* erscheint bei nur einem importierten Jahr genau einmal und wird hier
* nicht gefunden. Deshalb gibt es daneben die Liste der einzelnen groesseren
* Zahlungen - siehe einzelneZahlungen().
*/
export const MINDESTANZAHL = 2
export type GruppierungsOptionen = {
/** Schon abgehakte Schluessel - "das ist kein Vertrag". */
abgehakt?: ReadonlySet<string>
/** Hinterlegte Vertraege, zum Abgleich. */
vertraege?: VertragAbgleich[]
}
/** Ordnet Vertraege ihrem normalisierten Gesellschaftsnamen zu. */
function vertragsIndex(vertraege: VertragAbgleich[]): Map<string, VertragAbgleich> {
const index = new Map<string, VertragAbgleich>()
for (const vertrag of vertraege) {
const schluessel = normalisieren(vertrag.gesellschaft)
// Erster Treffer gewinnt. Zwei Vertraege bei derselben Gesellschaft sind
// moeglich (Hausrat und Haftpflicht bei der Allianz); fuer die Frage
// "ist das schon erklaert?" genuegt einer.
if (schluessel && !index.has(schluessel)) index.set(schluessel, vertrag)
}
return index
}
/**
* Waehlt aus mehreren Schreibweisen die, die man jemandem zeigen kann.
*
* Banken liefern denselben Empfaenger mal als "Allianz Lebensversicherung
* AG", mal als "ALLIANZ LEBENSVERS AG". Die laengste zu nehmen waere
* naheliegend - und faellt regelmaessig auf die geschriene Fassung herein,
* weil Versalien nicht kuerzer sind.
*
* Das ist mehr als Kosmetik: Dieser Name wird beim "Vertrag anlegen" in das
* Feld Gesellschaft uebernommen. Steht dort alles in Grossbuchstaben, tippt
* man es von Hand neu - und dann war die Vorbelegung wertlos.
*
* Also: erst die Schreibweisen mit Klein- und Grossbuchstaben, unter denen
* die laengste. Gibt es nur geschriene, nimmt man eben die laengste davon.
*/
export function besteSchreibweise(schreibweisen: string[]): string {
if (schreibweisen.length === 0) return ''
const gemischt = schreibweisen.filter((name) => name !== name.toUpperCase())
const auswahl = gemischt.length > 0 ? gemischt : schreibweisen
return auswahl.reduce((a, b) => (b.length > a.length ? b : a), auswahl[0]!)
}
/**
* Fasst Zahlungen zu Gruppen zusammen und bewertet sie.
*
* Erwartet werden ausschliesslich Ausgaben, die die betreute Person
* betreffen - die Auswahl trifft der Aufrufer, weil sie von der Datenbank
* kommt. Umbuchungen zwischen eigenen Konten gehoeren nicht hierher: Wer
* monatlich aufs Sparbuch legt, hat deswegen keinen Vertrag.
*/
export function gruppieren(
zahlungen: ZahlungEingabe[],
optionen: GruppierungsOptionen = {},
): Zahlungsgruppe[] {
const abgehakt = optionen.abgehakt ?? new Set<string>()
const index = vertragsIndex(optionen.vertraege ?? [])
const nachSchluessel = new Map<string, ZahlungEingabe[]>()
for (const zahlung of zahlungen) {
const schluessel = normalisieren(zahlung.gegenstelle)
// Ohne Gegenstelle gibt es nichts zu gruppieren. Solche Buchungen sind
// Bargeldabhebungen oder handerfasste Zeilen - kein Vertragshinweis.
if (!schluessel) continue
if (abgehakt.has(schluessel)) continue
const bisher = nachSchluessel.get(schluessel)
if (bisher) bisher.push(zahlung)
else nachSchluessel.set(schluessel, [zahlung])
}
const gruppen: Zahlungsgruppe[] = []
for (const [schluessel, posten] of nachSchluessel) {
if (posten.length < MINDESTANZAHL) continue
const sortiert = nachTagSortieren(posten, (p) => p.datum)
const betraege = posten.map((p) => p.betragCent)
const abstandTage = abstandInTagen(posten.map((p) => p.datum))
const rhythmus = abstandTage === null ? 'UNREGELMAESSIG' : rhythmusAusAbstand(abstandTage)
const betragCent = medianBigInt(betraege)
const jeJahr = ZAHLUNGEN_JE_JAHR[rhythmus]
const jahresbetragCent =
jeJahr > 0 ? betragCent * BigInt(jeJahr) : summe(betraege)
const schreibweisen = [
...new Set(posten.map((p) => (p.gegenstelle ?? '').trim()).filter(Boolean)),
].sort()
const vertrag = index.get(schluessel) ?? null
gruppen.push({
schluessel,
anzeigename: besteSchreibweise(schreibweisen) || schluessel,
schreibweisen,
anzahl: posten.length,
ersteZahlung: sortiert[0]!.datum,
letzteZahlung: sortiert[sortiert.length - 1]!.datum,
betragCent,
kleinsterBetragCent: betraege.reduce((a, b) => (b < a ? b : a), betraege[0]!),
groessterBetragCent: betraege.reduce((a, b) => (b > a ? b : a), betraege[0]!),
betragSchwankt: betraegeSchwanken(betraege),
rhythmus,
abstandTage,
jahresbetragCent,
vertragId: vertrag?.id ?? null,
vertragBezeichnung: vertrag?.bezeichnung ?? null,
buchungIds: sortiert.map((p) => p.id),
bereitsZugeordnet: posten.filter((p) => Boolean(p.vertragId)).length,
offeneBuchungIds: sortiert.filter((p) => !p.vertragId).map((p) => p.id),
})
}
// Absteigend nach Jahresbetrag: Was am meisten kostet, will man zuerst
// geklaert haben.
return gruppen.sort((a, b) =>
a.jahresbetragCent > b.jahresbetragCent
? -1
: a.jahresbetragCent < b.jahresbetragCent
? 1
: a.anzeigename.localeCompare(b.anzeigename, 'de'),
)
}
// -----------------------------------------------------------------------------
// Die Luecke: einmalige Zahlungen
// -----------------------------------------------------------------------------
/**
* Ab welchem Betrag eine einzelne Zahlung erwaehnenswert ist.
*
* 150 EUR ist die Grenze, ab der sich das Nachsehen lohnt. Darunter stehen
* Einkaeufe und Gebuehren, darueber faengt es an, nach Jahresbeitrag
* auszusehen.
*/
export const EINZELZAHLUNG_AB_CENT = 15_000n
export type Einzelzahlung = {
schluessel: string
anzeigename: string
buchungId: string
datum: Date
betragCent: bigint
vertragId: string | null
vertragBezeichnung: string | null
}
/**
* Einzelne groessere Zahlungen ohne hinterlegten Vertrag.
*
* Der Grund fuer diese zweite Liste: Die Gruppierung braucht mindestens
* zwei Zahlungen. Eine jaehrlich abgebuchte Versicherungspraemie erscheint
* bei einem importierten Jahr aber genau einmal - und ausgerechnet
* Versicherungen werden oft jaehrlich gezahlt. Ohne diese Liste waere die
* Suche genau dort blind, wo sie am meisten bringen soll.
*
* Sie ist ausdruecklich kein Vertragsverdacht, sondern eine Merkliste. In
* der Oberflaeche steht sie deshalb getrennt und mit eigener Ueberschrift.
*/
export function einzelneZahlungen(
zahlungen: ZahlungEingabe[],
optionen: GruppierungsOptionen = {},
): Einzelzahlung[] {
const abgehakt = optionen.abgehakt ?? new Set<string>()
const index = vertragsIndex(optionen.vertraege ?? [])
const anzahlJeSchluessel = new Map<string, number>()
for (const zahlung of zahlungen) {
const schluessel = normalisieren(zahlung.gegenstelle)
if (!schluessel) continue
anzahlJeSchluessel.set(schluessel, (anzahlJeSchluessel.get(schluessel) ?? 0) + 1)
}
const treffer: Einzelzahlung[] = []
for (const zahlung of zahlungen) {
const schluessel = normalisieren(zahlung.gegenstelle)
if (!schluessel) continue
if (abgehakt.has(schluessel)) continue
// Alles, was mehrfach vorkommt, steht bereits in der Gruppenliste.
if ((anzahlJeSchluessel.get(schluessel) ?? 0) >= MINDESTANZAHL) continue
if (zahlung.betragCent < EINZELZAHLUNG_AB_CENT) continue
const vertrag = index.get(schluessel) ?? null
treffer.push({
schluessel,
anzeigename: (zahlung.gegenstelle ?? '').trim() || schluessel,
buchungId: zahlung.id,
datum: zahlung.datum,
betragCent: zahlung.betragCent,
vertragId: vertrag?.id ?? null,
vertragBezeichnung: vertrag?.bezeichnung ?? null,
})
}
return treffer.sort((a, b) =>
a.betragCent > b.betragCent ? -1 : a.betragCent < b.betragCent ? 1 : 0,
)
}
// -----------------------------------------------------------------------------
// Fuer die Anzeige
// -----------------------------------------------------------------------------
/**
* Der Satz, der unter einer Gruppe steht.
*
* Er nennt die Grundlage der Einordnung, statt nur das Ergebnis zu
* behaupten - damit man widersprechen kann, wenn es nicht stimmt.
*/
export function rhythmusErklaerung(gruppe: Zahlungsgruppe): string {
const zahlungen = `${gruppe.anzahl} ${gruppe.anzahl === 1 ? 'Zahlung' : 'Zahlungen'}`
if (gruppe.rhythmus === 'UNREGELMAESSIG' || gruppe.abstandTage === null) {
return `${zahlungen}, ohne erkennbaren Abstand`
}
const tage = gruppe.abstandTage
const monate = Math.round(tage / 30)
const abstand =
monate <= 1 ? 'etwa monatlich' : `etwa alle ${monate} Monate`
return `${zahlungen}, ${abstand}`
}
/**
* Uebersetzt den erkannten Rhythmus in den Zahlrhythmus des Vertrags.
*
* Zwei Faelle haben dort keine Entsprechung: ZWEIMONATLICH kennt das
* Vertragsmodell nicht, und UNREGELMAESSIG ist keine Aussage, sondern das
* Eingestaendnis, keine zu haben. Beide liefern null - dann bleibt im
* Formular die Vorgabe stehen, statt etwas Falsches vorzublenden. Eine
* vorbelegte falsche Angabe wird uebersehen; ein leeres Feld nicht.
*/
export function alsZahlRhythmus(rhythmus: Rhythmus): string | null {
switch (rhythmus) {
case 'MONATLICH':
return 'MONATLICH'
case 'VIERTELJAEHRLICH':
return 'VIERTELJAEHRLICH'
case 'HALBJAEHRLICH':
return 'HALBJAEHRLICH'
case 'JAEHRLICH':
return 'JAEHRLICH'
default:
return null
}
}
/**
* Sucht den Vertrag, zu dem eine Gegenstelle EINDEUTIG passt.
*
* Gedacht fuer die automatische Zuordnung beim Import. Die Betonung liegt
* auf eindeutig: Passen zwei Vertraege (Hausrat und Haftpflicht bei
* derselben Gesellschaft), wird nichts zugeordnet. Eine falsche Zuordnung
* waere schlimmer als gar keine - sie behauptet einen Beleg, den es nicht
* gibt, und niemand sieht noch einmal hin.
*
* Gibt null zurueck, wenn nichts oder mehr als eines passt.
*/
export function eindeutigerVertrag(
gegenstelle: string | null | undefined,
vertraege: VertragAbgleich[],
): VertragAbgleich | null {
const schluessel = normalisieren(gegenstelle)
if (!schluessel) return null
const treffer = vertraege.filter((vertrag) => normalisieren(vertrag.gesellschaft) === schluessel)
return treffer.length === 1 ? treffer[0]! : null
}
/** Wie viele Gruppen warten noch auf eine Entscheidung? Fuer das Dashboard. */
export function offeneKandidaten(gruppen: Zahlungsgruppe[]): number {
return gruppen.filter((gruppe) => gruppe.vertragId === null).length
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Browserseitige Hilfen fuer Passkeys.
*
* Bewusst ohne "server-only" und ohne Datenbankzugriff: Diese Datei laeuft
* im Browser. Die eigentliche Pruefung passiert auf dem Server
* (src/lib/auth/passkey.ts) - hier geht es nur darum, das Geraet
* anzusprechen und seine Fehler in verstaendliche Saetze zu uebersetzen.
*/
import {
browserSupportsWebAuthn,
startAuthentication,
startRegistration,
} from '@simplewebauthn/browser'
export { browserSupportsWebAuthn, startAuthentication, startRegistration }
/**
* Uebersetzt die Ausnahmen der WebAuthn-Schnittstelle.
*
* Der Browser wirft DOMExceptions mit sehr knappen Namen und einer
* englischen Beschreibung, die man niemandem zeigen kann. Vor allem
* "NotAllowedError" ist irrefuehrend: Er bedeutet meistens schlicht, dass
* der Vorgang abgebrochen wurde oder die Zeit abgelaufen ist - nicht, dass
* etwas verboten waere.
*/
export function passkeyBrowserfehler(fehler: unknown, beimEinrichten = false): string {
const name = fehler instanceof Error ? fehler.name : ''
switch (name) {
case 'NotAllowedError':
return 'Abgebrochen oder zu lange gewartet. Du kannst es gleich noch einmal versuchen.'
case 'InvalidStateError':
return beimEinrichten
? 'Für dieses Gerät ist hier bereits ein Passkey hinterlegt. Ein zweiter bringt nichts — ' +
'nimm ein anderes Gerät oder entferne zuerst den vorhandenen.'
: 'Dieses Gerät kann den Passkey gerade nicht verwenden.'
case 'SecurityError':
return (
'Die Adresse im Browser passt nicht zu der, für die der Passkey ausgestellt wurde. ' +
'Das ist genau der Schutz, der Phishing verhindert — prüfe die Adresszeile.'
)
case 'NotSupportedError':
return 'Dieses Gerät unterstützt keine Passkeys mit Fingerabdruck, Gesicht oder PIN.'
case 'AbortError':
return 'Abgebrochen.'
default:
return 'Der Passkey konnte nicht verwendet werden. Bitte versuche es noch einmal.'
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Die Arten von Schreiben, die die Anwendung erzeugen kann.
*
* Rein und ohne Datenbank - der Entwurfstext entsteht anderswo
* (src/lib/schreiben/entwurf.ts), hier steht nur, was eine Art ausmacht.
*
* Warum ueberhaupt eine Aufzaehlung? Damit die Wiedervorlagefrist,
* die Beschriftung und der Dokumenttitel an EINER Stelle stehen. Vorher
* lagen sie verstreut: 28 Tage in rueckkaufswert.ts, 42 in
* sachstandsanfrage.ts, die Beschriftungen jeweils in der Seite.
*/
export type SchreibenArt = 'RUECKKAUFSWERT' | 'KUENDIGUNG' | 'SACHSTAND' | 'FREI'
export const SCHREIBEN_ARTEN: readonly SchreibenArt[] = [
'RUECKKAUFSWERT',
'KUENDIGUNG',
'SACHSTAND',
'FREI',
]
export type SchreibenEigenschaften = {
beschriftung: string
/**
* Vorgeschlagene Wiedervorlage in Tagen.
*
* null heisst: keine vorschlagen. Bei einem freien Schreiben weiss
* niemand, ob und wann eine Antwort kommt - eine erfundene Frist waere
* schlechter als keine.
*/
wiedervorlageTage: number | null
/** Woran haengt das Schreiben? Bestimmt, was die Seite laden muss. */
bezug: 'VERTRAG' | 'VORGANG' | 'KEINER'
/** Kurze Erklaerung, warum die Frist so lang ist. */
fristBegruendung: string | null
}
export const SCHREIBEN_EIGENSCHAFTEN: Record<SchreibenArt, SchreibenEigenschaften> = {
RUECKKAUFSWERT: {
beschriftung: 'Auskunft über Rückkaufswert',
// Dieselbe Zahl wie RUECKKAUFSWERT_WIEDERVORLAGE_TAGE - dort steht sie
// fuer die Anzeige am Vertrag, hier fuer die erzeugte Frist. Ein Test
// haelt beide zusammen.
wiedervorlageTage: 28,
bezug: 'VERTRAG',
fristBegruendung:
'Vier Wochen sind bei Versicherern die übliche Bearbeitungszeit. Kommt bis dahin ' +
'nichts, lohnt das Nachfassen.',
},
KUENDIGUNG: {
beschriftung: 'Kündigung',
// Kuerzer als beim Rueckkaufswert: Eine Kuendigung muss bestaetigt
// werden, und ohne Bestaetigung ist nicht belegt, dass sie ankam.
wiedervorlageTage: 21,
bezug: 'VERTRAG',
fristBegruendung:
'Drei Wochen. Eine Kündigung ohne schriftliche Bestätigung ist nicht nachgewiesen — ' +
'und wenn sie untergegangen ist, läuft die Frist weiter.',
},
SACHSTAND: {
beschriftung: 'Sachstandsanfrage',
wiedervorlageTage: 42,
bezug: 'VORGANG',
fristBegruendung:
'Sechs Wochen. Behörden brauchen länger als Versicherer; früher nachzufassen bringt ' +
'meist nur die Antwort, dass es noch dauert.',
},
FREI: {
beschriftung: 'Freies Schreiben',
wiedervorlageTage: null,
bezug: 'KEINER',
fristBegruendung: null,
},
}
/** Ist der Wert eine bekannte Art? Fuer Parameter aus der Adresszeile. */
export function istSchreibenArt(wert: string | null | undefined): wert is SchreibenArt {
return typeof wert === 'string' && (SCHREIBEN_ARTEN as readonly string[]).includes(wert)
}
/**
* Dateiname fuer die Word-Datei und die Ablage.
*
* Sprechend, sortierbar, ohne Zeichen, die ein Dateisystem nicht mag:
* "2026-08-23_Kuendigung_Allianz.docx"
*/
export function dateinameFuerSchreiben(
art: SchreibenArt,
empfaenger: string | null,
datum: Date,
endung: 'docx' | 'pdf' | 'eml',
): string {
const tag = datum.toISOString().slice(0, 10)
const sauber = (text: string) =>
text
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae')
.replace(/Ö/g, 'Oe')
.replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.replace(/[^A-Za-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
const teile = [tag, sauber(SCHREIBEN_EIGENSCHAFTEN[art].beschriftung)]
const ziel = empfaenger ? sauber(empfaenger) : ''
if (ziel) teile.push(ziel)
return `${teile.join('_')}.${endung}`
}
+118
View File
@@ -0,0 +1,118 @@
import 'server-only'
import {
AlignmentType,
Document,
Packer,
Paragraph,
TextRun,
convertMillimetersToTwip,
} from 'docx'
/**
* Ein Brief als Word-Datei.
*
* Getrennt von src/lib/berichte/docx.ts, und das ist Absicht. Der dortige
* Satz baut einen BERICHT: Titel, Kopfzeilen mit betreuter Person,
* Aktenzeichen und Berichtszeitraum, am Ende ein Unterschriftenblock. Ein
* Brief braucht etwas anderes - Absender, Anschriftenfeld, Datumszeile
* rechts, Betreff, Anrede, Grussformel. Beides in eine Funktion zu zwingen
* haette beide Ausgaben schlechter gemacht.
*
* Warum ueberhaupt .docx und nicht nur PDF? Weil jeder Fall eine
* Besonderheit hat, die in den Brief gehoert. Wer das Schreiben vor dem
* Absenden noch anfassen will, braucht eine Datei, die sich anfassen
* laesst. Die Fassung fuer die Akte ist ein PDF - siehe
* src/lib/schreiben/pdf.tsx.
*
* Das Layout folgt DIN 5008 lose: Anschriftenfeld oben links, Datum
* rechts, Betreff fett. Nicht auf den Millimeter - der Brief geht in einen
* gewoehnlichen Umschlag, nicht in ein Fensterkuvert der Behoerde.
*/
const SCHRIFT = 'Arial'
const GROESSE = 11
/**
* Der Entwurfstext kommt als eine Zeichenkette mit Zeilenumbruechen.
*
* Er ist bereits vollstaendig gesetzt: Absender, Anschrift, Datum,
* Betreff, Anrede, Text, Gruss. Diese Funktion formatiert ihn nur - sie
* denkt sich nichts dazu und laesst nichts weg. Was der Benutzer im
* Browser gesehen hat, steht auch in der Datei.
*/
function absatz(
text: string,
optionen: { fett?: boolean; rechts?: boolean; abstandNach?: number } = {},
): Paragraph {
return new Paragraph({
alignment: optionen.rechts ? AlignmentType.RIGHT : AlignmentType.LEFT,
spacing: { after: optionen.abstandNach ?? 0, line: 276 },
children: [
new TextRun({
text,
bold: optionen.fett ?? false,
size: GROESSE * 2, // docx rechnet in halben Punkten
font: SCHRIFT,
}),
],
})
}
/**
* Erkennt die Betreffzeile, damit sie fett gesetzt werden kann.
*
* Die erzeugten Schreiben beginnen sie mit "Betreff:" - siehe
* betreffZeile() in src/lib/vertraege/anschreiben.ts. Steht dort einmal
* etwas anderes, wird die Zeile eben normal gesetzt; ein Brief mit
* unfettem Betreff ist immer noch ein brauchbarer Brief.
*/
function istBetreff(zeile: string): boolean {
return /^Betreff:/i.test(zeile.trim())
}
/**
* Die Datumszeile steht rechts, wie im deutschen Geschaeftsbrief.
* Erkennbar daran, dass sie mit einem Ort und Komma oder direkt mit einem
* Datum beginnt und sonst nichts enthaelt.
*/
function istDatumszeile(zeile: string): boolean {
return /^([A-Za-zÄÖÜäöüß .-]+,\s*)?\d{1,2}\.\d{1,2}\.\d{4}$/.test(zeile.trim())
}
export function briefAlsDocx(text: string): Document {
const zeilen = text.split('\n')
const inhalt: Paragraph[] = zeilen.map((zeile) => {
if (zeile.trim() === '') return absatz('', { abstandNach: 120 })
if (istBetreff(zeile)) return absatz(zeile, { fett: true, abstandNach: 120 })
if (istDatumszeile(zeile)) return absatz(zeile, { rechts: true })
return absatz(zeile)
})
return new Document({
creator: 'Betreuungs-Cockpit',
description: 'Schreiben aus der Betreuungsakte',
title: 'Schreiben',
sections: [
{
properties: {
page: {
margin: {
top: convertMillimetersToTwip(25),
right: convertMillimetersToTwip(20),
// Links etwas mehr: Da wird gelocht und abgeheftet.
left: convertMillimetersToTwip(25),
bottom: convertMillimetersToTwip(20),
},
},
},
children: inhalt,
},
],
})
}
/** Fertige .docx-Datei als Buffer. */
export async function briefDocxErzeugen(text: string): Promise<Buffer> {
return Packer.toBuffer(briefAlsDocx(text))
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Aus dem Brieftext eine E-Mail machen.
*
* Rein und ohne Datenbank, damit sich jede Regel einzeln testen laesst -
* und ohne "server-only", weil die mailto-Pruefung auch im Browser
* gebraucht wird.
*
* Der Kern ist emailFassung(): Sie nimmt den fertigen Brieftext und
* entfernt, was in einer E-Mail nichts verloren hat - Absenderblock,
* Anschriftenfeld, Datumszeile, Betreffzeile (der Betreff steht im
* Betrefffeld der Mail). Was bleibt, ist Anrede, Inhalt samt
* Bestellungsabsatz und Grussformel.
*
* Warum aus dem Brieftext ableiten statt getrennt erzeugen? Weil der
* Benutzer den Brief womoeglich schon bearbeitet hat. Eine getrennt
* erzeugte E-Mail wuerfe diese Aenderungen weg - und dann stuende im
* Brief etwas anderes als in der Mail, ohne dass es jemand merkt.
*/
/**
* Ab wann ist ein mailto-Link nicht mehr verlaesslich?
*
* mailto uebergibt den Text in der Adresszeile. Die Grenzen sind je nach
* Browser, Betriebssystem und Mailprogramm verschieden und nirgends
* zugesichert; ab etwa 2000 Zeichen wird es unzuverlaessig - unter
* Windows schneidet die Uebergabe an das Mailprogramm teils frueher ab.
*
* 1800 Zeichen NACH der URL-Kodierung lassen etwas Luft. Ein
* abgeschnittener Brief ist schlimmer als ein fehlender Knopf: Er faellt
* erst beim Empfaenger auf.
*/
export const MAILTO_HOECHSTLAENGE = 1800
export type EmailFassung = {
betreff: string
text: string
}
/** Erkennt die Betreffzeile des Briefes. */
function istBetreff(zeile: string): boolean {
return /^Betreff:/i.test(zeile.trim())
}
/** Erkennt die Datumszeile ("Ort, 23.08.2026" oder nur das Datum). */
function istDatumszeile(zeile: string): boolean {
return /^([A-Za-zÄÖÜäöüß .-]+,\s*)?\d{1,2}\.\d{1,2}\.\d{4}$/.test(zeile.trim())
}
/** Erkennt den Beginn des eigentlichen Inhalts: die Anrede. */
function istAnrede(zeile: string): boolean {
return /^Sehr geehrte/i.test(zeile.trim())
}
/**
* Leitet die E-Mail-Fassung aus dem Brieftext ab.
*
* Alles VOR der Anrede ist Briefkopf - Absender, Anschrift, Datum,
* Betreff - und faellt weg. Der Betreff wird dabei herausgelesen und
* getrennt zurueckgegeben; er gehoert ins Betrefffeld, nicht in den
* Rumpf.
*
* Findet sich keine Anrede (der Benutzer hat den Text frei umgebaut),
* wird NICHTS entfernt: Lieber ein Briefkopf zu viel in der Mail als
* stillschweigend verlorener Inhalt.
*/
export function emailFassung(brieftext: string, betreffVorgabe = ''): EmailFassung {
const zeilen = brieftext.split('\n')
let betreff = betreffVorgabe
const betreffZeile = zeilen.find(istBetreff)
if (betreffZeile) {
const roh = betreffZeile.replace(/^Betreff:\s*/i, '').trim()
if (roh) betreff = roh
}
const anredeIndex = zeilen.findIndex(istAnrede)
if (anredeIndex === -1) {
// Kein erkennbarer Aufbau - unveraendert lassen, nur den Betreff
// nicht doppelt fuehren.
const ohneBetreff = zeilen.filter((zeile) => !istBetreff(zeile))
return { betreff, text: bereinigen(ohneBetreff) }
}
// Ab der Anrede ist alles Inhalt. Datums- und Betreffzeilen davor sind
// damit automatisch draussen; zur Sicherheit filtern wir sie auch im
// Rumpf, falls der Text unueblich aufgebaut ist.
const rumpf = zeilen
.slice(anredeIndex)
.filter((zeile) => !istBetreff(zeile) && !istDatumszeile(zeile))
return { betreff, text: bereinigen(rumpf) }
}
/** Hoechstens eine Leerzeile am Stueck, keine am Anfang oder Ende. */
function bereinigen(zeilen: string[]): string {
const ergebnis: string[] = []
for (const zeile of zeilen) {
if (zeile.trim() === '' && ergebnis[ergebnis.length - 1]?.trim() === '') continue
ergebnis.push(zeile.trimEnd())
}
while (ergebnis[0]?.trim() === '') ergebnis.shift()
while (ergebnis[ergebnis.length - 1]?.trim() === '') ergebnis.pop()
return ergebnis.join('\n')
}
/**
* Baut den mailto-Link - oder gibt null zurueck, wenn er zu lang wuerde.
*
* null heisst fuer die Oberflaeche: den Knopf gar nicht erst anbieten.
* Ein Knopf, der einen halben Brief uebergibt, waere schlimmer als
* keiner - der Verlust faellt erst beim Empfaenger auf.
*/
export function mailtoLink(
adresse: string,
fassung: EmailFassung,
): string | null {
if (!adresse.trim()) return null
const link =
`mailto:${encodeURIComponent(adresse.trim())}` +
`?subject=${encodeURIComponent(fassung.betreff)}` +
`&body=${encodeURIComponent(fassung.text)}`
return link.length <= MAILTO_HOECHSTLAENGE ? link : null
}
/**
* Sehr grobe Formpruefung einer E-Mail-Adresse.
*
* Bewusst locker: Die Adresse kommt aus den eigenen Kontakten oder wird
* gleich im Mailprogramm sichtbar. Eine strenge Pruefung wuerde nur
* gueltige, aber ungewoehnliche Adressen abweisen.
*/
export function siehtAusWieEmail(wert: string | null | undefined): boolean {
if (!wert) return false
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(wert.trim())
}
+95
View File
@@ -0,0 +1,95 @@
import 'server-only'
/**
* Eine .eml-Datei bauen - der E-Mail-Entwurf zum Oeffnen im Mailprogramm.
*
* Warum .eml zusaetzlich zu mailto? mailto uebergibt den Text in der
* Adresszeile, und deren Laenge ist nirgends zugesichert. Die Briefe hier
* sind lang (Bestellungsabsatz, sechs Auskunftspunkte) - eine .eml-Datei
* traegt den vollen Text garantiert, egal wie lang.
*
* Das Format ist RFC 5322: Kopfzeilen, Leerzeile, Rumpf. Die Kopfzeile
* "X-Unsent: 1" bittet das Mailprogramm, die Datei als ENTWURF zu oeffnen
* statt als empfangene Nachricht - Outlook und Thunderbird respektieren
* das. Ein Programm, das sie ignoriert, zeigt die Mail eben als Nachricht
* mit Antwort-Moeglichkeit; auch damit laesst sich arbeiten.
*
* Ohne Absenderadresse ("From"): Die traegt das Mailprogramm beim Senden
* selbst ein. Eine erfundene Adresse hier wuerde je nach Programm sogar
* uebernommen - und dann ginge die Mail mit falschem Absender hinaus.
*/
/**
* Kodiert Text als quoted-printable (RFC 2045).
*
* Noetig fuer die Umlaute: .eml ist ein Textformat mit ASCII-Erwartung.
* Base64 ginge auch, aber quoted-printable bleibt lesbar, wenn jemand die
* Datei im Editor oeffnet - und bei einem Schreiben in einer
* Betreuungssache ist Nachvollziehbarkeit ein Wert fuer sich.
*/
export function quotedPrintable(text: string): string {
const bytes = Buffer.from(text.replace(/\r?\n/g, '\r\n'), 'utf8')
let ergebnis = ''
let zeilenlaenge = 0
for (const byte of bytes) {
let stueck: string
if (byte === 0x0d || byte === 0x0a) {
// Zeilenumbruch unkodiert - er strukturiert die Mail.
ergebnis += String.fromCharCode(byte)
zeilenlaenge = 0
continue
}
const istDruckbar =
(byte >= 33 && byte <= 126 && byte !== 61) || byte === 32 || byte === 9
if (istDruckbar) {
stueck = String.fromCharCode(byte)
} else {
stueck = `=${byte.toString(16).toUpperCase().padStart(2, '0')}`
}
// RFC 2045: hoechstens 76 Zeichen je Zeile, weiche Umbrueche mit "=".
if (zeilenlaenge + stueck.length > 75) {
ergebnis += '=\r\n'
zeilenlaenge = 0
}
ergebnis += stueck
zeilenlaenge += stueck.length
}
return ergebnis
}
/**
* Kodiert eine Kopfzeile mit Umlauten (RFC 2047, "encoded word").
*
* "Betreff: Rückkaufswert" wuerde als rohe Kopfzeile von manchen
* Programmen verstuemmelt. ASCII-Betreffs bleiben unkodiert - lesbarer.
*/
export function kopfzeileKodieren(text: string): string {
// eslint-disable-next-line no-control-regex
if (!/[^\x20-\x7E]/.test(text)) return text
return `=?UTF-8?B?${Buffer.from(text, 'utf8').toString('base64')}?=`
}
export type EmlEingabe = {
an: string
betreff: string
text: string
}
/** Baut die .eml-Datei als Buffer. */
export function emlErzeugen(eingabe: EmlEingabe): Buffer {
const kopf = [
`To: ${kopfzeileKodieren(eingabe.an)}`,
`Subject: ${kopfzeileKodieren(eingabe.betreff)}`,
'X-Unsent: 1',
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: quoted-printable',
].join('\r\n')
return Buffer.from(`${kopf}\r\n\r\n${quotedPrintable(eingabe.text)}\r\n`, 'utf8')
}
+242
View File
@@ -0,0 +1,242 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import {
absenderBlock,
anschriftBlock,
betreffZeile,
bestellungsAbsatz,
datumsZeile,
grussformel,
type Briefkopf,
type Empfaenger,
} from '@/lib/vertraege/anschreiben'
import { rueckkaufswertAnschreiben } from '@/lib/vertraege/rueckkaufswert'
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { heuteAlsTag } from '@/lib/fristen/datum'
import { SCHREIBEN_EIGENSCHAFTEN, type SchreibenArt } from '@/lib/schreiben/arten'
/**
* Baut den Entwurf eines Schreibens - Text, Betreff, Empfaenger.
*
* Hier laufen die vorhandenen Erzeuger zusammen (Rueckkaufswert,
* Kuendigung, Sachstandsanfrage). Sie bleiben unveraendert: Ihr Wortlaut
* ist getestet und in der Praxis erprobt, und ihn fuer eine neue
* Ausgabeform umzuschreiben waere ein Risiko ohne Gewinn.
*
* Was hier dazukommt, ist der vierte Fall: das freie Schreiben an eine
* Stelle, zu der es noch keinen Datensatz gibt. Am Anfang einer Betreuung
* ist das der Normalfall - man fragt ja gerade an, um zu erfahren, ob dort
* ueberhaupt etwas besteht.
*/
export type SchreibenEntwurf = {
art: SchreibenArt
betreff: string
/** Vollstaendiger Brieftext, bearbeitbar. */
text: string
empfaengerName: string
empfaengerAnschrift: string
/** Vorgeschlagenes Datum der Wiedervorlage, ISO-Tag oder leer. */
wiedervorlageAm: string
/** Woran haengt das Schreiben? */
vertragId: string | null
vorgangId: string | null
/** Was vor dem Abschicken zu klaeren ist - aus dem Kuendigungsassistenten. */
warnungen: string[]
}
function tagAlsIsoText(datum: Date): string {
return datum.toISOString().slice(0, 10)
}
function wiedervorlageVorschlag(art: SchreibenArt, heute: Date): string {
const tage = SCHREIBEN_EIGENSCHAFTEN[art].wiedervorlageTage
if (tage === null) return ''
const ziel = new Date(heute)
ziel.setUTCDate(ziel.getUTCDate() + tage)
return tagAlsIsoText(ziel)
}
/**
* Der Rumpf eines freien Schreibens.
*
* Bewusst kurz und mit sichtbaren Luecken in eckigen Klammern. Ein
* vorformulierter Text, der fast passt, wird abgeschickt, ohne gelesen zu
* werden; eine offensichtliche Luecke nicht.
*
* Der Bestellungsabsatz steht drin und nicht zur Wahl: Ohne den Nachweis
* der Vertretungsmacht kommt die Auskunft nicht, und das faellt erst nach
* vier Wochen auf.
*/
function freierEntwurf(kopf: Briefkopf, empfaenger: Empfaenger, heute: Date): string {
return [
absenderBlock(kopf),
'',
anschriftBlock(empfaenger),
'',
datumsZeile(kopf.betreuerOrt, heute),
'',
betreffZeile('[Betreff eintragen]', null, kopf.betreuteName, kopf.betreuteGeburtsdatum),
'',
'Sehr geehrte Damen und Herren,',
'',
bestellungsAbsatz(kopf),
'',
'[Hier steht dein Anliegen.]',
'',
'Für Rückfragen stehe ich gerne zur Verfügung.',
'',
grussformel(kopf.betreuerName),
'',
'Anlage: Kopie der Bestellungsurkunde',
].join('\n')
}
/** Zieht die Betreffzeile aus einem fertigen Brieftext. */
export function betreffAusText(text: string): string {
const zeile = text.split('\n').find((z) => /^Betreff:/i.test(z.trim()))
return zeile ? zeile.replace(/^Betreff:\s*/i, '').trim() : ''
}
export type EntwurfEingabe = {
betreuteId: string
benutzerName: string
art: SchreibenArt
vertragId?: string | null
vorgangId?: string | null
/** Fuer den Test ueberschreibbar. */
heute?: Date
}
export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEntwurf> {
const heute = eingabe.heute ?? heuteAlsTag()
const betreute = await db.betreute.findFirst({
where: { id: eingabe.betreuteId, ...nichtGeloescht },
})
if (!betreute) throw new Error('Die betreute Person gibt es nicht (mehr).')
const kopf = briefkopfBauen({
betreute: oeffnen('Betreute', betreute),
benutzer: { name: eingabe.benutzerName },
})
const gemeinsam = {
art: eingabe.art,
wiedervorlageAm: wiedervorlageVorschlag(eingabe.art, heute),
vertragId: eingabe.vertragId ?? null,
vorgangId: eingabe.vorgangId ?? null,
warnungen: [] as string[],
}
// --- Vertragsbezogene Schreiben -------------------------------------------
if (eingabe.art === 'RUECKKAUFSWERT' || eingabe.art === 'KUENDIGUNG') {
if (!eingabe.vertragId) throw new Error('Für dieses Schreiben fehlt der Vertrag.')
const vertrag = await db.vertrag.findFirst({
where: { id: eingabe.vertragId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
})
if (!vertrag) throw new Error('Diesen Vertrag gibt es nicht (mehr).')
const offen = oeffnen('Vertrag', vertrag)
const empfaenger: Empfaenger = { gesellschaft: vertrag.gesellschaft, anschrift: null }
const bezeichnung = `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`
if (eingabe.art === 'RUECKKAUFSWERT') {
const text = rueckkaufswertAnschreiben({
kopf,
empfaenger,
vertragsbezeichnung: bezeichnung,
vertragsnummer: offen.vertragsnummer,
stichtag: heute,
heute,
})
return {
...gemeinsam,
text,
betreff: betreffAusText(text),
empfaengerName: vertrag.gesellschaft ?? '',
empfaengerAnschrift: '',
}
}
const paket = kuendigungsassistent({
kopf,
empfaenger,
vertragsbezeichnung: bezeichnung,
vertragsnummer: offen.vertragsnummer,
art: vertrag.art,
laufzeit: {
beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
},
kuendigungZum: vertrag.kuendigungZum,
heute,
})
return {
...gemeinsam,
text: paket.schreiben,
betreff: betreffAusText(paket.schreiben),
empfaengerName: vertrag.gesellschaft ?? '',
empfaengerAnschrift: '',
warnungen: paket.warnungen,
}
}
// --- Sachstandsanfrage zum Vorgang ----------------------------------------
if (eingabe.art === 'SACHSTAND') {
if (!eingabe.vorgangId) throw new Error('Für dieses Schreiben fehlt der Vorgang.')
const vorgang = await db.vorgang.findFirst({
where: { id: eingabe.vorgangId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
include: {
kontakte: { select: { name: true, organisation: true }, take: 1 },
},
})
if (!vorgang) throw new Error('Diesen Vorgang gibt es nicht (mehr).')
const anfrage = sachstandsanfrageErzeugen({
vorgangTitel: vorgang.titel,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
letzterKontaktAm: vorgang.letzterKontaktAm,
betreute: {
vorname: betreute.vorname,
nachname: betreute.nachname,
geburtsdatum: betreute.geburtsdatum,
},
betreuer: {
name: eingabe.benutzerName,
aktenzeichenGericht: betreute.aktenzeichenGericht,
betreuungsgericht: betreute.betreuungsgericht,
},
heute,
})
return {
...gemeinsam,
text: anfrage.text,
betreff: anfrage.betreff,
empfaengerName: vorgang.gegenstelle ?? '',
empfaengerAnschrift: '',
}
}
// --- Freies Schreiben ------------------------------------------------------
const text = freierEntwurf(kopf, { gesellschaft: null, anschrift: null }, heute)
return {
...gemeinsam,
text,
betreff: '',
empfaengerName: '',
empfaengerAnschrift: '',
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'server-only'
import * as React from 'react'
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
import { fuerStandardschrift, pdfErzeugen } from '@/lib/pdf/erzeugen'
/**
* Das Schreiben als PDF - die Fassung, die in die Akte geht.
*
* Warum PDF und nicht die Word-Datei?
*
* Erstens der Ablageschutz: src/lib/speicher/dateityp.ts laesst nur PDF und
* Bilder in den Objektspeicher, und zwar nach den Magic Bytes, nicht nach
* Endung oder gemeldetem Typ. Eine .docx ist ein ZIP-Archiv; sie
* durchzulassen hiesse, jedes ZIP durchzulassen. Diese Pruefung ist eine
* bewusste Zusage der Anwendung, und sie fuer eine Bequemlichkeit
* aufzuweichen waere der falsche Handel.
*
* Zweitens die Sache selbst: In der Akte liegt der Nachweis, was
* hinausgegangen ist. Dafuer ist ein Format, das sich nicht mehr aendert,
* das richtige. Die .docx ist die Arbeitsfassung zum Ausdrucken und
* Unterschreiben - sie liegt beim Benutzer, nicht in der Akte.
*
* Zu beachten: Abgelegt wird der Text, wie er beim Ablegen im Browser
* stand. Wer die Word-Datei danach noch aendert, hat in der Akte eine
* andere Fassung als im Briefkasten. Die Oberflaeche sagt das dazu.
*/
const stile = StyleSheet.create({
seite: {
paddingTop: 70,
paddingBottom: 56,
paddingLeft: 71, // 25 mm - da wird gelocht
paddingRight: 57,
fontSize: 10.5,
lineHeight: 1.45,
fontFamily: 'Helvetica',
color: '#111111',
},
zeile: { marginBottom: 0 },
leer: { height: 9 },
betreff: { fontFamily: 'Helvetica-Bold', marginBottom: 4 },
datum: { textAlign: 'right' },
fuss: {
position: 'absolute',
bottom: 28,
left: 71,
right: 57,
fontSize: 7.5,
color: '#777777',
textAlign: 'center',
},
})
function istBetreff(zeile: string): boolean {
return /^Betreff:/i.test(zeile.trim())
}
function istDatumszeile(zeile: string): boolean {
return /^([A-Za-zÄÖÜäöüß .-]+,\s*)?\d{1,2}\.\d{1,2}\.\d{4}$/.test(zeile.trim())
}
export function Briefdokument({
text,
erzeugtAm,
}: {
text: string
erzeugtAm: Date
}): React.JSX.Element {
const zeilen = text.split('\n')
return (
<Document>
<Page size="A4" style={stile.seite}>
{zeilen.map((zeile, i) => {
if (zeile.trim() === '') return <View key={i} style={stile.leer} />
const stil = istBetreff(zeile)
? [stile.zeile, stile.betreff]
: istDatumszeile(zeile)
? [stile.zeile, stile.datum]
: stile.zeile
return (
<Text key={i} style={stil}>
{fuerStandardschrift(zeile)}
</Text>
)
})}
<Text style={stile.fuss} fixed>
{fuerStandardschrift(
`Aus dem Betreuungs-Cockpit erzeugt am ${erzeugtAm.toLocaleDateString('de-DE')}. ` +
'Diese Fassung liegt in der Akte.',
)}
</Text>
</Page>
</Document>
)
}
/** Fertiges PDF als Buffer, fuer die Ablage im Objektspeicher. */
export async function briefPdfErzeugen(text: string, erzeugtAm: Date): Promise<Buffer> {
return pdfErzeugen(<Briefdokument text={text} erzeugtAm={erzeugtAm} />)
}
+1
View File
@@ -229,6 +229,7 @@ export const buchungSchema = z
verwendungszweck: optionalerText(500),
belegId: optionaleId,
belegFehltBegruendung: optionalerText(300),
vertragId: optionaleId,
betrifftBetreute: schalter,
vermoegenswirksam: schalter,
})
+1 -1
View File
@@ -17,7 +17,7 @@
*/
/** Muss zur "version" in der package.json passen. */
export const VERSION = '1.0.5'
export const VERSION = '1.5.0'
/**
* Kurzer Git-Stand, beim Bauen hineingereicht:
+13
View File
@@ -3,6 +3,7 @@ import { db, papierkorbGrenze } from '@/lib/db'
import { env } from '@/lib/env'
import { fristErinnerung, mailSenden } from '@/lib/mail'
import { alteVersucheEntfernen } from '@/lib/auth/ratelimit'
import { abgelaufeneAnfragenEntfernen } from '@/lib/auth/passkey'
/**
* Taeglicher Wartungslauf.
@@ -14,6 +15,7 @@ import { alteVersucheEntfernen } from '@/lib/auth/ratelimit'
* 2. Papierkorb leeren (30 Tage)
* 3. Alte Anmeldeversuche entfernen (30 Tage)
* 4. Altes Audit-Log entfernen (10 Jahre, ueber die DB-Funktion)
* 5. Liegengebliebene Passkey-Challenges entfernen (5 Minuten)
*
* Jeder Schritt faengt seine Fehler selbst ab. Faellt die Mail aus, soll
* trotzdem aufgeraeumt werden - sonst laeuft die Datenbank voll, weil ein
@@ -25,6 +27,7 @@ export type Wartungsergebnis = {
papierkorbGeleert: number
loginversucheEntfernt: number
auditEintraegeEntfernt: number
passkeyAnfragenEntfernt: number
fehler: string[]
}
@@ -56,6 +59,7 @@ export async function wartungslauf(): Promise<Wartungsergebnis> {
papierkorbGeleert: 0,
loginversucheEntfernt: 0,
auditEintraegeEntfernt: 0,
passkeyAnfragenEntfernt: 0,
fehler: [],
}
@@ -93,6 +97,15 @@ export async function wartungslauf(): Promise<Wartungsergebnis> {
ergebnis.fehler.push(`Audit-Log: ${beschreiben(fehler)}`)
}
// --- 5. Passkey-Challenges ----------------------------------------------
try {
// Normalerweise raeumt jede Anmeldung selbst auf. Bleibt der letzte
// Versuch des Tages liegen, faengt es dieser Lauf ab.
ergebnis.passkeyAnfragenEntfernt = await abgelaufeneAnfragenEntfernen()
} catch (fehler) {
ergebnis.fehler.push(`Passkey-Anfragen: ${beschreiben(fehler)}`)
}
return ergebnis
}
+198
View File
@@ -11,6 +11,7 @@ import { blindindex } from '@/lib/crypto/hash'
import { alsTag } from '@/lib/finanzen/datum'
import { csvTextLesen, csvZuBuchungen, dublettenMarkieren } from '@/lib/finanzen/import'
import { camtZuBuchungen } from '@/lib/finanzen/camt'
import { eindeutigerVertrag } from '@/lib/finanzen/wiederkehrend'
import {
buchungSchema,
fehlerAuflisten,
@@ -349,6 +350,10 @@ export async function buchungSpeichern(
kategorie: werte.kategorie,
gegenstelle: werte.gegenstelle,
belegId: werte.belegId,
vertragId: werte.vertragId,
// Von Hand gesetzt heisst: nicht mehr "automatisch". Wer die
// Zuordnung anfasst, uebernimmt sie.
vertragAutomatisch: false,
belegFehltBegruendung: werte.belegId ? null : werte.belegFehltBegruendung,
betrifftBetreute: werte.betrifftBetreute,
vermoegenswirksam: werte.vermoegenswirksam,
@@ -515,8 +520,18 @@ export async function importAusfuehren(
const anzulegen = zeilen.filter((z) => werte.dublettenUebernehmen || !z.dublette)
// Vertraege einmal holen, nicht je Zeile. Zugeordnet wird nur, wo die
// Gegenstelle zu genau EINEM Vertrag passt - bei Mehrdeutigkeit
// geschieht nichts.
const vertraege = await db.vertrag.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
})
let angelegt = 0
let zugeordnet = 0
for (const zeile of anzulegen) {
const passenderVertrag = eindeutigerVertrag(zeile.gegenstelle, vertraege)
try {
await db.buchung.create({
data: {
@@ -530,10 +545,17 @@ export async function importAusfuehren(
gegenstelle: zeile.gegenstelle,
betrifftBetreute: werte.betrifftBetreute,
importReferenz: werte.dublettenUebernehmen && zeile.dublette ? null : zeile.importReferenz,
// Nur bei Ausgaben: Eine Gutschrift der Versicherung ist keine
// Praemienzahlung und darf nicht als "durch Vertrag belegt"
// gelten.
...(zeile.richtung === 'AUSGABE' && passenderVertrag
? { vertragId: passenderVertrag.id, vertragAutomatisch: true }
: {}),
...sealen('Buchung', { verwendungszweck: zeile.verwendungszweck }),
},
})
angelegt++
if (zeile.richtung === 'AUSGABE' && passenderVertrag) zugeordnet++
} catch (ausnahme) {
// Die Eindeutigkeit auf (kontoId, importReferenz) faengt Reste ab,
// die zwischen Lesen und Schreiben dazugekommen sind.
@@ -563,6 +585,13 @@ export async function importAusfuehren(
? [`${gelesen.fehler.length} Zeile(n) konnten nicht gelesen werden:`]
: []),
...gelesen.fehler.slice(0, 10).map((f) => `Zeile ${f.zeilennummer}: ${f.meldung}`),
...(zugeordnet > 0
? [
`${zugeordnet} Buchung(en) wurden automatisch einem Vertrag zugeordnet und gelten ` +
'damit als belegt. Sie sind in der Liste als automatisch zugeordnet erkennbar – ' +
'bitte einmal durchsehen.',
]
: []),
'Alle importierten Buchungen stehen zunächst in „Sonstige Einnahmen“ bzw. „Sonstige Ausgaben“. ' +
'Bitte die Kategorien nachtragen – danach stimmen die Berichte.',
]
@@ -821,3 +850,172 @@ export async function wertstandLoeschen(
revalidatePath('/finanzen/vermoegen')
return { erfolg: 'Wert gelöscht.' }
}
// -----------------------------------------------------------------------------
// Wiederkehrende Zahlungen
// -----------------------------------------------------------------------------
/**
* Hakt eine Gruppe wiederkehrender Zahlungen als "kein Vertrag" ab.
*
* Der Schluessel kommt aus normalisieren() und wird hier NICHT noch einmal
* gebildet: Was der Benutzer auf dem Bildschirm gesehen hat, soll auch
* abgehakt werden. Bildeten Anzeige und Aktion den Schluessel getrennt,
* koennten sie auseinanderlaufen - und dann verschwaende eine andere
* Gruppe als die angeklickte.
*/
export async function zahlungsgruppeAbhaken(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const schluessel = String(daten.get('schluessel') ?? '').trim()
const anzeigename = String(daten.get('anzeigename') ?? '').trim()
const begruendung = String(daten.get('begruendung') ?? '').trim()
if (!schluessel || !anzeigename) {
return { fehler: 'Diese Zahlungsgruppe lässt sich nicht zuordnen.' }
}
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
await db.zahlungsgruppeGeklaert.upsert({
where: { betreuteId_schluessel: { betreuteId, schluessel } },
create: {
betreuteId,
schluessel,
anzeigename,
begruendung: begruendung === '' ? null : begruendung,
},
update: { begruendung: begruendung === '' ? null : begruendung },
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'ZahlungsgruppeGeklaert',
bezeichnung: `${anzeigename} — kein Vertrag`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Zahlungsgruppe konnte nicht abgehakt werden:', ausnahme)
return { fehler: 'Die Zahlungsgruppe konnte nicht abgehakt werden.' }
}
revalidatePath('/finanzen/wiederkehrend')
revalidatePath('/heute')
return { erfolg: `„${anzeigename}“ gilt als geklärt.` }
}
/** Holt eine abgehakte Gruppe zurueck in die Liste. */
export async function zahlungsgruppeZurueckholen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const eintragId = String(daten.get('eintragId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
// Zeilenweise Pruefung: Die ID muss zu DIESER betreuten Person gehoeren.
const eintrag = await db.zahlungsgruppeGeklaert.findFirst({
where: { id: eintragId, betreuteId },
select: { id: true, anzeigename: true },
})
if (!eintrag) return { fehler: 'Diesen Eintrag gibt es nicht (mehr).' }
await db.zahlungsgruppeGeklaert.delete({ where: { id: eintrag.id } })
await protokollieren({
aktion: 'GELOESCHT',
entitaet: 'ZahlungsgruppeGeklaert',
entitaetId: eintrag.id,
bezeichnung: `${eintrag.anzeigename} — wieder offen`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Zahlungsgruppe konnte nicht zurückgeholt werden:', ausnahme)
return { fehler: 'Der Eintrag konnte nicht zurückgeholt werden.' }
}
revalidatePath('/finanzen/wiederkehrend')
revalidatePath('/heute')
return { erfolg: 'Die Gruppe steht wieder in der Liste.' }
}
/**
* Ordnet mehrere Buchungen auf einmal einem Vertrag zu.
*
* Der Weg dorthin fuehrt ueber "Wiederkehrende Zahlungen": Dort steht eine
* Gruppe gleichartiger Abbuchungen beisammen, und wenn dazu ein Vertrag in
* der Akte liegt, sind es genau die Buchungen, die er belegt. Sie einzeln
* anzuklicken waere die Arbeit, die dieses Issue abschaffen soll.
*
* Die IDs kommen aus dem Formular und werden NICHT geglaubt: Geschrieben
* wird nur, was zu dieser betreuten Person gehoert.
*/
export async function zahlungenVertragZuordnen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const vertragId = String(daten.get('vertragId') ?? '')
const buchungIds = String(daten.get('buchungIds') ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (!vertragId || buchungIds.length === 0) {
return { fehler: 'Es ist nichts zum Zuordnen ausgewählt.' }
}
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const vertrag = await db.vertrag.findFirst({
where: { id: vertragId, betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
})
if (!vertrag) return { fehler: 'Diesen Vertrag gibt es nicht (mehr).' }
// betreuteId im WHERE ist der eigentliche Schutz: Eine fremde
// Buchungs-ID trifft dann auf null Zeilen.
const { count } = await db.buchung.updateMany({
where: { id: { in: buchungIds }, betreuteId, ...nichtGeloescht },
data: { vertragId: vertrag.id, vertragAutomatisch: false },
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'Buchung',
bezeichnung: `${count} Buchung(en) als Dauerzahlung zu „${vertrag.bezeichnung}“`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/finanzen')
revalidatePath('/finanzen/buchungen')
revalidatePath('/finanzen/wiederkehrend')
revalidatePath('/heute')
return { erfolg: `${count} Buchung(en) gelten jetzt als durch den Vertrag belegt.` }
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Sammelzuordnung fehlgeschlagen:', ausnahme)
return { fehler: 'Die Zuordnung konnte nicht gespeichert werden.' }
}
}
+313
View File
@@ -0,0 +1,313 @@
'use server'
import { AuthError } from 'next-auth'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/server'
import { signIn } from '@/auth'
import { db } from '@/lib/db'
import { protokollieren } from '@/lib/audit'
import {
PASSKEY_MELDUNG,
PasskeyFehler,
anmeldungPruefen,
anmeldungVorbereiten,
registrierungPruefen,
registrierungVorbereiten,
} from '@/lib/auth/passkey'
import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung'
/**
* Server Actions rund um Passkeys.
*
* Ein Passkey-Vorgang besteht immer aus zwei Aufrufen: erst holt der Browser
* die Optionen samt Challenge, dann schickt er die Antwort des Geraets
* zurueck. Dazwischen liegt der Griff zum Fingerabdrucksensor, und der
* dauert unbestimmt lange - deshalb zwei Schritte und keine Sitzungsvariable.
*
* Die Antworten des Authenticators kommen als JSON-Zeichenkette herein, nicht
* als Objekt. Grund: Was ueber die Grenze zum Server geht, wird hier
* ausdruecklich geparst und geprueft, statt sich auf die Serialisierung von
* React zu verlassen.
*/
/** Ergebnis eines Schritts, der Optionen liefert. */
export type PasskeyStart<T> = { ok: true; optionen: T } | { ok: false; fehler: string }
/** Ergebnis eines abschliessenden Schritts. */
export type PasskeyErgebnis = { ok: true; meldung?: string } | { ok: false; fehler: string }
function alsFehler(fehler: unknown): { ok: false; fehler: string } {
if (fehler instanceof PasskeyFehler) {
return { ok: false, fehler: PASSKEY_MELDUNG[fehler.grund] }
}
console.error('[passkey] Unerwarteter Fehler:', fehler)
return { ok: false, fehler: PASSKEY_MELDUNG.ungueltig }
}
/**
* Parst die Antwort des Browsers.
*
* Absichtlich nur eine grobe Formpruefung: Was zaehlt, prueft anschliessend
* die Bibliothek kryptografisch. Hier geht es nur darum, dass kein
* offensichtlicher Unsinn bis dorthin durchlaeuft.
*/
const AntwortSchema = z.object({
id: z.string().min(1),
rawId: z.string().min(1),
type: z.literal('public-key'),
response: z.object({ clientDataJSON: z.string().min(1) }).passthrough(),
})
function antwortLesen<T>(roh: string): T {
const gelesen = AntwortSchema.safeParse(JSON.parse(roh))
if (!gelesen.success) throw new PasskeyFehler('ungueltig')
return gelesen.data as T
}
// ---------------------------------------------------------------------------
// Einrichten (angemeldet, unter "Mein Konto")
// ---------------------------------------------------------------------------
export async function passkeyEinrichtungStarten(): Promise<
PasskeyStart<PublicKeyCredentialCreationOptionsJSON>
> {
try {
const benutzer = await erfordereBenutzer()
return { ok: true, optionen: await registrierungVorbereiten(benutzer) }
} catch (fehler) {
return alsFehler(fehler)
}
}
const NameSchema = z
.string()
.trim()
.min(1, 'Bitte gib dem Gerät einen Namen.')
.max(60, 'Bitte höchstens 60 Zeichen.')
export async function passkeyEinrichtungAbschliessen(
name: string,
antwortJson: string,
): Promise<PasskeyErgebnis> {
try {
const benutzer = await erfordereBenutzer()
const geprueft = NameSchema.safeParse(name)
if (!geprueft.success) {
return {
ok: false,
fehler: geprueft.error.issues[0]?.message ?? 'Der Name passt nicht.',
}
}
const antwort = antwortLesen<RegistrationResponseJSON>(antwortJson)
const passkey = await registrierungPruefen(benutzer, geprueft.data, antwort)
await protokollieren({
aktion: 'PASSKEY_REGISTRIERT',
entitaet: 'Passkey',
entitaetId: passkey.id,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
bezeichnung: passkey.name,
})
revalidatePath('/konto')
return { ok: true, meldung: `„${passkey.name}“ ist eingerichtet.` }
} catch (fehler) {
return alsFehler(fehler)
}
}
// ---------------------------------------------------------------------------
// Verwalten
// ---------------------------------------------------------------------------
export async function passkeyEntfernen(id: string): Promise<PasskeyErgebnis> {
try {
const benutzer = await erfordereBenutzer()
// Zeilenweise Pruefung: Es genuegt nicht, dass die ID existiert - sie
// muss zu DIESEM Benutzer gehoeren. deleteMany mit userId im WHERE
// erledigt Pruefung und Loeschen in einem Zug; ein fremder Passkey
// trifft dann auf null Zeilen.
const passkey = await db.passkey.findFirst({
where: { id, userId: benutzer.id },
select: { id: true, name: true },
})
if (!passkey)
return {
ok: false,
fehler: 'Dieser Passkey gehört nicht zu deinem Zugang.',
}
await db.passkey.delete({ where: { id: passkey.id } })
await protokollieren({
aktion: 'PASSKEY_ENTFERNT',
entitaet: 'Passkey',
entitaetId: passkey.id,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
bezeichnung: passkey.name,
})
revalidatePath('/konto')
return { ok: true, meldung: `„${passkey.name}“ wurde entfernt.` }
} catch (fehler) {
return alsFehler(fehler)
}
}
// ---------------------------------------------------------------------------
// Anmelden (nicht angemeldet)
// ---------------------------------------------------------------------------
export async function passkeyAnmeldungStarten(): Promise<
PasskeyStart<PublicKeyCredentialRequestOptionsJSON>
> {
try {
return { ok: true, optionen: await anmeldungVorbereiten() }
} catch (fehler) {
return alsFehler(fehler)
}
}
/** Verhindert offene Weiterleitungen auf fremde Seiten. */
function sicheresZiel(ziel: string): string {
if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute'
return ziel
}
/**
* Erkennt die Weiterleitung, die Next.js intern als Ausnahme wirft.
* Wie in anmelden.ts ueber das digest-Feld statt ueber einen Import aus
* next/dist - interne Pfade aendern sich zwischen Nebenversionen.
*/
function istWeiterleitung(fehler: unknown): boolean {
return (
typeof fehler === 'object' &&
fehler !== null &&
'digest' in fehler &&
typeof (fehler as { digest?: unknown }).digest === 'string' &&
(fehler as { digest: string }).digest.startsWith('NEXT_REDIRECT')
)
}
/**
* Uebersetzt den Fehlercode aus authorize() in einen lesbaren Satz.
* "passkey:<grund>" kommt aus PasskeyAnmeldeFehler, alles andere aus dem
* gewoehnlichen Anmeldeweg.
*/
function fehlertext(code: string): string {
const [art, wert] = code.split(':')
if (art === 'passkey') {
const grund = wert as keyof typeof PASSKEY_MELDUNG
return PASSKEY_MELDUNG[grund] ?? PASSKEY_MELDUNG.ungueltig
}
if (art === 'gesperrt') {
const minuten = Math.ceil(Number.parseInt(wert ?? '0', 10) / 60)
return (
`Zu viele Fehlversuche. Aus Sicherheitsgründen ist die Anmeldung für ` +
`${minuten > 1 ? `etwa ${minuten} Minuten` : 'kurze Zeit'} gesperrt.`
)
}
if (art === 'konto-inaktiv') {
return 'Dieser Zugang ist deaktiviert. Bitte wende dich an den Administrator.'
}
return PASSKEY_MELDUNG.ungueltig
}
export async function passkeyAnmeldungAbschliessen(
antwortJson: string,
weiter: string,
): Promise<PasskeyErgebnis> {
try {
// Nur die Form pruefen; signIn() macht die eigentliche Arbeit.
antwortLesen<AuthenticationResponseJSON>(antwortJson)
} catch (fehler) {
return alsFehler(fehler)
}
try {
await signIn('passkey', {
antwort: antwortJson,
redirectTo: sicheresZiel(weiter),
})
} catch (fehler) {
// Bei Erfolg wirft signIn eine Weiterleitung - die muss durch.
if (istWeiterleitung(fehler)) throw fehler
if (fehler instanceof AuthError) {
const code =
'code' in fehler && typeof fehler.code === 'string' ? fehler.code : 'passkey:ungueltig'
return { ok: false, fehler: fehlertext(code) }
}
throw fehler
}
// Wird nicht erreicht - signIn leitet weiter.
return { ok: true }
}
// ---------------------------------------------------------------------------
// Erneut bestaetigen (angemeldet, vor sensiblen Aktionen)
// ---------------------------------------------------------------------------
export async function passkeyBestaetigungStarten(): Promise<
PasskeyStart<PublicKeyCredentialRequestOptionsJSON>
> {
try {
await erfordereBenutzer()
return { ok: true, optionen: await anmeldungVorbereiten() }
} catch (fehler) {
return alsFehler(fehler)
}
}
/**
* Bestaetigt die laufende Sitzung mit einem Passkey.
*
* Wichtig ist die letzte Pruefung: Der Passkey muss dem angemeldeten
* Benutzer gehoeren. Sonst koennte jemand mit seinem eigenen Passkey die
* fremde Sitzung freischalten, die auf einem unbeaufsichtigten Rechner
* offen steht - genau der Fall, gegen den die erneute Bestaetigung da ist.
*/
export async function passkeyBestaetigen(antwortJson: string): Promise<PasskeyErgebnis> {
try {
const benutzer = await erfordereBenutzer()
const antwort = antwortLesen<AuthenticationResponseJSON>(antwortJson)
const { benutzer: eigentuemer } = await anmeldungPruefen(antwort)
if (eigentuemer.id !== benutzer.id) {
await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
entitaet: 'User',
entitaetId: benutzer.id,
userId: benutzer.id,
userEmail: benutzer.email,
bezeichnung: 'Bestätigung mit fremdem Passkey',
})
return {
ok: false,
fehler: 'Dieser Passkey gehört zu einem anderen Zugang als dem gerade angemeldeten.',
}
}
await reauthBestaetigen(benutzer.id)
return { ok: true }
} catch (fehler) {
return alsFehler(fehler)
}
}
+310
View File
@@ -0,0 +1,310 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db, nichtGeloescht } from '@/lib/db'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { protokollieren } from '@/lib/audit'
import { ablegenMitTyp } from '@/lib/speicher'
import { heuteAlsTag } from '@/lib/fristen/datum'
import { tagAusText } from '@/lib/finanzen/datum'
import {
SCHREIBEN_EIGENSCHAFTEN,
dateinameFuerSchreiben,
istSchreibenArt,
type SchreibenArt,
} from '@/lib/schreiben/arten'
import { briefPdfErzeugen } from '@/lib/schreiben/pdf'
import { emailFassung } from '@/lib/schreiben/email'
/**
* Server Actions rund um erzeugte Schreiben.
*
* Der Kern ist "In die Akte legen". Bisher endete ein Schreiben beim Text
* zum Kopieren; alles danach - ausdrucken, abschicken, als Dokument
* nachtragen, Wiedervorlage setzen - war Handarbeit, und der letzte
* Schritt wurde am haeufigsten vergessen. Dann fehlt der Nachweis genau
* dort, wo er gebraucht wird: im Bericht ans Gericht.
*
* Hier passiert alles in einem Zug, und zwar so, dass ein Abbruch nichts
* Halbes hinterlaesst.
*/
export type SchreibenZustand = {
fehler?: string
erfolg?: string
/** Kurze Aufzaehlung dessen, was angelegt wurde. */
meldung?: string[]
}
function zugriffsfehler(ausnahme: unknown): SchreibenZustand | null {
if (ausnahme instanceof ZugriffVerweigert) return { fehler: ausnahme.message }
return null
}
const ablegenSchema = z.object({
betreuteId: z.string().min(1),
art: z.string().refine(istSchreibenArt, 'Unbekannte Art von Schreiben.'),
betreff: z.string().trim().min(1, 'Bitte einen Betreff eintragen.').max(200),
text: z.string().trim().min(30, 'Der Text ist zu kurz für ein Schreiben.').max(50_000),
empfaengerName: z.string().trim().max(200).default(''),
versandAm: z.string().trim().default(''),
wiedervorlageAm: z.string().trim().default(''),
vertragId: z.string().trim().default(''),
vorgangId: z.string().trim().default(''),
/** "BRIEF" oder "EMAIL" - bestimmt Fassung und Kennzeichnung. */
versandweg: z.enum(['BRIEF', 'EMAIL']).default('BRIEF'),
empfaengerEmail: z.string().trim().max(200).default(''),
})
/**
* Legt das Schreiben als Ausgangsdokument in die Akte und setzt die
* Wiedervorlage.
*
* Reihenfolge mit Absicht: Erst die Datei in den Objektspeicher, dann der
* Datensatz. Bricht das Ablegen ab, gibt es keinen Dokumentdatensatz, der
* auf eine Datei zeigt, die es nicht gibt. Umgekehrt bliebe im
* schlimmsten Fall eine verwaiste Datei liegen - die kostet Platz, aber
* sie luegt niemanden an.
*/
export async function schreibenAblegen(
_zustand: SchreibenZustand,
daten: FormData,
): Promise<SchreibenZustand> {
const gelesen = ablegenSchema.safeParse({
betreuteId: daten.get('betreuteId'),
art: daten.get('art'),
betreff: daten.get('betreff'),
text: daten.get('text'),
empfaengerName: daten.get('empfaengerName') ?? '',
versandweg: daten.get('versandweg') ?? 'BRIEF',
empfaengerEmail: daten.get('empfaengerEmail') ?? '',
versandAm: daten.get('versandAm') ?? '',
wiedervorlageAm: daten.get('wiedervorlageAm') ?? '',
vertragId: daten.get('vertragId') ?? '',
vorgangId: daten.get('vorgangId') ?? '',
})
if (!gelesen.success) {
return { fehler: gelesen.error.issues[0]?.message ?? 'Bitte prüfe deine Eingaben.' }
}
const werte = gelesen.data
const art = werte.art as SchreibenArt
const betreuteId = werte.betreuteId
try {
const { benutzer } = await erfordereZugang(betreuteId, 'DOKUMENTE', 'schreiben')
const versandAm = tagAusText(werte.versandAm) ?? heuteAlsTag()
// --- Bezuege pruefen, bevor irgendetwas geschrieben wird ---------------
const vertrag = werte.vertragId
? await db.vertrag.findFirst({
where: { id: werte.vertragId, betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
})
: null
if (werte.vertragId && !vertrag) return { fehler: 'Diesen Vertrag gibt es nicht (mehr).' }
const vorgang = werte.vorgangId
? await db.vorgang.findFirst({
where: { id: werte.vorgangId, betreuteId, ...nichtGeloescht },
select: { id: true, titel: true },
})
: null
if (werte.vorgangId && !vorgang) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
// --- Datei zuerst -------------------------------------------------------
// Bei einer E-Mail wird die E-Mail-Fassung abgelegt, nicht der
// Brieftext: In der Akte soll stehen, was tatsaechlich hinausging -
// samt Empfaengeradresse im Kopf der PDF.
const istEmail = werte.versandweg === 'EMAIL'
const fassung = istEmail ? emailFassung(werte.text, werte.betreff) : null
const ablageText = fassung
? [
`E-Mail an: ${werte.empfaengerEmail || werte.empfaengerName || '[Adresse]'}`,
`Betreff: ${fassung.betreff}`,
'',
fassung.text,
].join('\n')
: werte.text
const pdf = await briefPdfErzeugen(ablageText, versandAm)
const dateiname = dateinameFuerSchreiben(art, werte.empfaengerName || null, versandAm, 'pdf')
const ablage = await ablegenMitTyp(betreuteId, 'dokumente', dateiname, pdf, 'application/pdf')
// --- Dann der Datensatz -------------------------------------------------
const dokument = await db.dokument.create({
data: {
betreuteId,
titel: (istEmail ? `E-Mail: ${werte.betreff}` : werte.betreff).slice(0, 200),
art: 'SCHREIBEN',
richtung: 'AUSGANG',
schreibenVom: versandAm,
versandAm,
empfaenger: werte.empfaengerName || null,
objektSchluessel: ablage.schluessel,
dateiname: ablage.dateiname,
mimeTyp: ablage.typ.mimeTyp,
groesseBytes: ablage.groesseBytes,
pruefsumme: ablage.pruefsumme,
// Was wir selbst gesetzt haben, muss nicht erkannt werden.
ocrStatus: 'nicht_verfuegbar',
// Ein eigenes Schreiben gehoert nicht in den Posteingang - es ist
// ja nichts angekommen, das noch zuzuordnen waere.
posteingangStatus: 'ZUGEORDNET',
...(vertrag || vorgang
? {
verknuepfungen: {
create: [
{
betreuteId,
...(vertrag ? { vertragId: vertrag.id } : {}),
...(vorgang ? { vorgangId: vorgang.id } : {}),
},
],
},
}
: {}),
},
select: { id: true, titel: true },
})
const meldung = [`Als Ausgangsdokument abgelegt: „${dokument.titel}“.`]
// --- Wiedervorlage ------------------------------------------------------
const wiedervorlage = tagAusText(werte.wiedervorlageAm)
if (wiedervorlage) {
const frist = await db.frist.create({
data: {
betreuteId,
vorgangId: vorgang?.id ?? null,
titel: `Antwort abwarten: ${werte.betreff}`.slice(0, 200),
beschreibung:
`Am ${versandAm.toLocaleDateString('de-DE')} verschickt` +
(werte.empfaengerName ? ` an ${werte.empfaengerName}` : '') +
'. Kommt bis zu diesem Tag nichts, nachfassen.',
datum: wiedervorlage,
typ: 'WIEDERVORLAGE',
verknuepfungen: { create: [{ betreuteId, dokumentId: dokument.id }] },
},
select: { id: true },
})
meldung.push(
`Wiedervorlage zum ${wiedervorlage.toLocaleDateString('de-DE')} angelegt.`,
)
void frist
} else if (SCHREIBEN_EIGENSCHAFTEN[art].wiedervorlageTage !== null) {
meldung.push('Ohne Wiedervorlage abgelegt — es erinnert dich also nichts daran.')
}
// --- Beim Vorgang festhalten, dass wir gerade geschrieben haben --------
if (vorgang) {
await db.vorgang.update({
where: { id: vorgang.id },
data: { letzterKontaktAm: versandAm },
})
}
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Dokument',
entitaetId: dokument.id,
bezeichnung: `Schreiben ausgegangen: ${dokument.titel}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/dokumente')
revalidatePath('/fristen')
revalidatePath('/heute')
if (vertrag) revalidatePath(`/vertraege/${vertrag.id}`)
if (vorgang) revalidatePath(`/vorgaenge/${vorgang.id}`)
return { erfolg: 'Das Schreiben liegt in der Akte.', meldung }
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[schreiben] Ablegen fehlgeschlagen:', ausnahme)
return { fehler: 'Das Schreiben konnte nicht abgelegt werden.' }
}
}
const kontaktSchema = z.object({
betreuteId: z.string().min(1),
name: z.string().trim().min(1, 'Bitte einen Namen eintragen.').max(200),
anschrift: z.string().trim().max(500).default(''),
})
/**
* Legt aus einer frei eingegebenen Anschrift einen Kontakt an.
*
* Damit man dieselbe Stelle beim naechsten Mal aus der Liste waehlen kann,
* statt sie erneut abzutippen. Genau so waechst das Adressbuch einer
* Betreuung: nicht durch Pflege, sondern nebenbei beim Schreiben.
*/
export async function kontaktAusAnschrift(
_zustand: SchreibenZustand,
daten: FormData,
): Promise<SchreibenZustand> {
const gelesen = kontaktSchema.safeParse({
betreuteId: daten.get('betreuteId'),
name: daten.get('empfaengerName'),
anschrift: daten.get('empfaengerAnschrift') ?? '',
})
if (!gelesen.success) {
return { fehler: gelesen.error.issues[0]?.message ?? 'Bitte prüfe deine Eingaben.' }
}
const { betreuteId, name, anschrift } = gelesen.data
try {
const { benutzer } = await erfordereZugang(betreuteId, 'KONTAKTE', 'schreiben')
// Die freie Anschrift kommt als Block; die erste Zeile ist die Strasse,
// die zweite PLZ und Ort. Mehr wird nicht geraten - was nicht sicher
// zuzuordnen ist, landet in den Notizen.
const zeilen = anschrift
.split('\n')
.map((z) => z.trim())
.filter(Boolean)
const strasse = zeilen[0] ?? null
const ortszeile = zeilen[1] ?? ''
const treffer = ortszeile.match(/^(\d{5})\s+(.+)$/)
const kontakt = await db.kontakt.create({
data: {
betreuteId,
name: name.slice(0, 200),
organisation: name.slice(0, 200),
rolle: 'SONSTIGES',
strasse,
plz: treffer?.[1] ?? null,
ort: treffer?.[2] ?? (treffer ? null : ortszeile || null),
},
select: { id: true, name: true },
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Kontakt',
entitaetId: kontakt.id,
bezeichnung: kontakt.name,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/kontakte')
return { erfolg: `„${kontakt.name}“ steht jetzt in den Kontakten.` }
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[schreiben] Kontakt konnte nicht angelegt werden:', ausnahme)
return { fehler: 'Der Kontakt konnte nicht angelegt werden.' }
}
}
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest'
import {
BETRAGS_TOLERANZ_PROZENT,
belegBegruendet,
belegDurchVertrag,
belegFehlt,
belegquote,
betragPasstZumVertrag,
betragPruefen,
type BuchungEingabe,
} from '@/lib/finanzen/kassenbuch'
/**
* Dauerzahlungen zu einem Vertrag.
*
* Bei einem Dauerschuldverhältnis ist der Vertrag der Beleg — für zwölf
* gleiche Lastschriften gibt es keine zwölf Quittungen, sondern eine Police.
*
* Der gefährliche Fehler wäre hier nicht ein zu strenges Urteil, sondern ein
* zu mildes: Wenn eine verdreifachte Prämie als „belegt" durchgeht, ist die
* Zuordnung eine Hintertür geworden. Die Hälfte dieser Prüfungen zielt
* deshalb darauf, dass die Anerkennung auch wirklich **wegfällt**.
*/
function buchung(teile: Partial<BuchungEingabe> = {}): BuchungEingabe {
return {
id: 'b1',
datum: new Date('2026-03-01T00:00:00Z'),
betragCent: 4250n,
richtung: 'AUSGABE',
kategorie: 'VERSICHERUNG',
gegenstelle: 'Allianz',
verwendungszweck: null,
kontoId: 'k1',
belegId: null,
belegFehltBegruendung: null,
betrifftBetreute: true,
vermoegenswirksam: false,
...teile,
}
}
/** Monatlicher Vertrag über 42,50 EUR. */
const MONATLICH = { vertragId: 'v1', vertragBeitragCent: 4250n, vertragRhythmus: 'MONATLICH' as const }
describe('Betrag gegen den hinterlegten Beitrag', () => {
it('erkennt die passende monatliche Rate', () => {
expect(betragPasstZumVertrag(4250n, 4250n, 'MONATLICH')).toBe(true)
})
it('lässt eine Beitragsanpassung durchgehen', () => {
// 46,00 statt 42,50 - knapp acht Prozent, das ist eine normale Anpassung.
expect(betragPasstZumVertrag(4600n, 4250n, 'MONATLICH')).toBe(true)
})
it('meldet die Prämie, die von 42 auf 420 springt', () => {
expect(betragPasstZumVertrag(42_000n, 4250n, 'MONATLICH')).toBe(false)
})
it('meldet auch die Zahlweise, die nicht zum Vertrag passt', () => {
// Am Vertrag steht die Monatsrate, die Bank bucht jährlich ab
// (12 x 42,50 = 510,00). Das gilt bewusst NICHT als in Ordnung: Dann
// stimmen die Vertragsdaten nicht, und das soll auffallen.
//
// Ein früherer Entwurf hat solche Umrechnungen zugelassen — und damit
// ein Toleranzband mit vier Löchern geschaffen. 420 EUR wären als
// "nahe am Jahresbetrag von 510" durchgegangen, also ausgerechnet der
// Fall, den diese Prüfung fangen soll.
expect(betragPasstZumVertrag(51_000n, 4250n, 'MONATLICH')).toBe(false)
})
it('akzeptiert die Rate, sobald die Zahlweise am Vertrag stimmt', () => {
// Derselbe Fall, nachdem die Zahlweise berichtigt wurde.
expect(betragPasstZumVertrag(51_000n, 51_000n, 'JAEHRLICH')).toBe(true)
})
it('antwortet „unbekannt“, wenn kein Beitrag hinterlegt ist', () => {
// Nicht "in Ordnung". Es soll nicht so aussehen, als wäre geprüft worden.
expect(betragPasstZumVertrag(4250n, null, 'MONATLICH')).toBeNull()
expect(betragPasstZumVertrag(4250n, 0n, 'MONATLICH')).toBeNull()
})
it('antwortet „unbekannt“ ohne Zahlweise und bei EINMALIG', () => {
expect(betragPasstZumVertrag(4250n, 4250n, null)).toBeNull()
// Zu einer Einmalzahlung gibt es keine wiederkehrende Rate.
expect(betragPasstZumVertrag(4250n, 4250n, 'EINMALIG')).toBeNull()
})
it('rechnet mit dem Betrag, nicht mit dem Vorzeichen', () => {
expect(betragPasstZumVertrag(-4250n, 4250n, 'MONATLICH')).toBe(true)
})
it('hält die dokumentierte Toleranz ein', () => {
expect(BETRAGS_TOLERANZ_PROZENT).toBe(20n)
// Genau an der Grenze: 42,50 + 20 % = 51,00.
expect(betragPasstZumVertrag(5100n, 4250n, 'MONATLICH')).toBe(true)
// Knapp darüber nicht mehr.
expect(betragPasstZumVertrag(5200n, 4250n, 'MONATLICH')).toBe(false)
})
})
describe('Belegzustand', () => {
it('zählt eine Dauerzahlung als belegt', () => {
const b = buchung(MONATLICH)
expect(belegDurchVertrag(b)).toBe(true)
expect(belegFehlt(b)).toBe(false)
expect(belegBegruendet(b)).toBe(false)
})
it('lässt die Anerkennung bei abweichendem Betrag wegfallen', () => {
// Der wichtigste Test: Sonst wäre die Zuordnung eine Hintertür.
const b = buchung({ ...MONATLICH, betragCent: 42_000n })
expect(belegDurchVertrag(b)).toBe(false)
expect(betragPruefen(b)).toBe(true)
expect(belegFehlt(b)).toBe(true)
})
it('erkennt eine Zuordnung an, wenn am Vertrag kein Beitrag steht', () => {
// Zuordnung gilt, aber es wird nicht behauptet, sie sei geprüft.
const b = buchung({ vertragId: 'v1', vertragBeitragCent: null, vertragRhythmus: null })
expect(belegDurchVertrag(b)).toBe(true)
expect(betragPruefen(b)).toBe(false)
})
it('lässt einen echten Beleg vorgehen', () => {
const b = buchung({ ...MONATLICH, belegId: 'd1' })
expect(belegDurchVertrag(b)).toBe(false)
expect(belegFehlt(b)).toBe(false)
})
it('bleibt ohne Zuordnung beim alten Verhalten', () => {
expect(belegFehlt(buchung())).toBe(true)
expect(belegBegruendet(buchung({ belegFehltBegruendung: 'Barzahlung' }))).toBe(true)
expect(belegFehlt(buchung({ belegFehltBegruendung: 'Barzahlung' }))).toBe(false)
})
it('lässt eine Begründung nicht neben den Vertrag treten', () => {
// Der Vertrag ist die stärkere Aussage; doppelt gezählt würde die
// Summe der Kategorien die Gesamtzahl übersteigen.
const b = buchung({ ...MONATLICH, belegFehltBegruendung: 'Dauerauftrag' })
expect(belegDurchVertrag(b)).toBe(true)
expect(belegBegruendet(b)).toBe(false)
})
})
describe('Belegquote', () => {
it('weist die drei Fälle getrennt aus', () => {
const quote = belegquote([
buchung({ id: '1', belegId: 'd1' }),
buchung({ id: '2', ...MONATLICH }),
buchung({ id: '3', belegFehltBegruendung: 'Barzahlung' }),
buchung({ id: '4' }),
])
expect(quote.gesamt).toBe(4)
expect(quote.mitBeleg).toBe(1)
expect(quote.durchVertrag).toBe(1)
expect(quote.begruendet).toBe(1)
expect(quote.ohneNachweis).toBe(1)
expect(quote.quoteProzent).toBe(75)
})
it('rechnet die Kategorien überschneidungsfrei', () => {
// Jede Buchung zählt in genau eine Kategorie - sonst ergäbe die Summe
// mehr als das Ganze.
const buchungen = [
buchung({ id: '1', belegId: 'd1' }),
buchung({ id: '2', ...MONATLICH }),
buchung({ id: '3', ...MONATLICH, belegFehltBegruendung: 'egal' }),
buchung({ id: '4', belegFehltBegruendung: 'Barzahlung' }),
buchung({ id: '5' }),
buchung({ id: '6', ...MONATLICH, betragCent: 99_000n }),
]
const q = belegquote(buchungen)
expect(q.mitBeleg + q.durchVertrag + q.begruendet + q.ohneNachweis).toBe(q.gesamt)
})
it('zählt eine abweichende Zahlung als zu prüfen und nicht als belegt', () => {
const quote = belegquote([buchung({ ...MONATLICH, betragCent: 42_000n })])
expect(quote.durchVertrag).toBe(0)
expect(quote.zuPruefen).toBe(1)
expect(quote.ohneNachweis).toBe(1)
expect(quote.quoteProzent).toBe(0)
})
it('bleibt bei null Buchungen bei 100 Prozent', () => {
expect(belegquote([]).quoteProzent).toBe(100)
})
})
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest'
import {
MAILTO_HOECHSTLAENGE,
emailFassung,
mailtoLink,
siehtAusWieEmail,
} from '@/lib/schreiben/email'
import { emlErzeugen, kopfzeileKodieren, quotedPrintable } from '@/lib/schreiben/eml'
/**
* Schreiben als E-Mail.
*
* Zwei Gefahren stehen im Mittelpunkt: dass beim Umbau vom Brief zur Mail
* stillschweigend Inhalt verloren geht (vor allem der Bestellungsabsatz),
* und dass ein mailto-Link einen halben Brief übergibt, ohne dass es
* jemand merkt.
*/
const BRIEF = [
'Florian Wilde',
'Musterstraße 1',
'96450 Coburg',
'',
'Allianz Lebensversicherungs-AG',
'[Straße und Hausnummer]',
'[PLZ und Ort]',
'',
'23.08.2026',
'',
'Betreff: Auskunft über Rückkaufswert — Franziska Wilde, geb. 23.09.1963',
'',
'Sehr geehrte Damen und Herren,',
'',
'ich bin durch Beschluss des Amtsgerichts Lichtenfels (Az. XVII 355/25) ' +
'zum rechtlichen Betreuer bestellt.',
'',
'für ein laufendes Verfahren benötige ich folgende Auskünfte.',
'',
'Mit freundlichen Grüßen',
'Florian Wilde',
].join('\n')
describe('E-Mail-Fassung aus dem Brief', () => {
it('entfernt Absender, Anschrift, Datum und Betreffzeile', () => {
const fassung = emailFassung(BRIEF)
expect(fassung.text).not.toContain('Musterstraße')
expect(fassung.text).not.toContain('[Straße und Hausnummer]')
expect(fassung.text).not.toContain('23.08.2026')
expect(fassung.text).not.toContain('Betreff:')
})
it('beginnt mit der Anrede und behält den Bestellungsabsatz', () => {
// Der Absatz ist der Grund, warum überhaupt geantwortet wird.
const fassung = emailFassung(BRIEF)
expect(fassung.text.startsWith('Sehr geehrte Damen und Herren,')).toBe(true)
expect(fassung.text).toContain('Amtsgerichts Lichtenfels')
expect(fassung.text).toContain('Mit freundlichen Grüßen')
})
it('zieht den Betreff aus der Betreffzeile', () => {
const fassung = emailFassung(BRIEF)
expect(fassung.betreff).toContain('Auskunft über Rückkaufswert')
expect(fassung.betreff).toContain('Franziska Wilde')
})
it('lässt einen frei umgebauten Text unangetastet', () => {
// Ohne erkennbare Anrede wird nichts entfernt: Lieber ein Briefkopf zu
// viel in der Mail als stillschweigend verlorener Inhalt.
const frei = 'Hallo Frau Maier,\nhier die gewünschten Unterlagen.\nViele Grüße'
const fassung = emailFassung(frei, 'Unterlagen')
expect(fassung.text).toContain('Hallo Frau Maier,')
expect(fassung.text).toContain('Viele Grüße')
expect(fassung.betreff).toBe('Unterlagen')
})
it('faltet mehrfache Leerzeilen zusammen', () => {
const fassung = emailFassung(BRIEF)
expect(fassung.text).not.toContain('\n\n\n')
})
})
describe('mailto-Link', () => {
const fassung = { betreff: 'Kurzer Betreff', text: 'Kurzer Text.' }
it('baut den Link mit Adresse, Betreff und Text', () => {
const link = mailtoLink('service@allianz.de', fassung)
expect(link).not.toBeNull()
expect(link).toContain('mailto:service%40allianz.de')
expect(link).toContain('subject=Kurzer%20Betreff')
expect(link).toContain('body=Kurzer%20Text.')
})
it('verweigert den Link bei zu langem Text, statt abzuschneiden', () => {
// Der Kern der Beides-Entscheidung: Ein Knopf, der einen halben Brief
// übergibt, wäre schlimmer als keiner.
const lang = { betreff: 'B', text: 'ä'.repeat(MAILTO_HOECHSTLAENGE) }
expect(mailtoLink('a@b.de', lang)).toBeNull()
})
it('verweigert den Link ohne Adresse', () => {
expect(mailtoLink('', fassung)).toBeNull()
expect(mailtoLink(' ', fassung)).toBeNull()
})
it('rechnet die Grenze nach der Kodierung, nicht davor', () => {
// 900 Umlaute sind roh unter der Grenze, kodiert (je 3 Byte -> %C3%A4,
// 6 Zeichen) weit darüber.
const umlaute = { betreff: 'B', text: 'ä'.repeat(900) }
expect(mailtoLink('a@b.de', umlaute)).toBeNull()
})
})
describe('Adressprüfung', () => {
it('erkennt gewöhnliche Adressen', () => {
expect(siehtAusWieEmail('service@allianz.de')).toBe(true)
expect(siehtAusWieEmail('info@stadt-coburg.bayern.de')).toBe(true)
})
it('weist Offensichtliches ab', () => {
expect(siehtAusWieEmail('')).toBe(false)
expect(siehtAusWieEmail(null)).toBe(false)
expect(siehtAusWieEmail('kein-at-zeichen.de')).toBe(false)
expect(siehtAusWieEmail('zwei @ leerzeichen@x.de')).toBe(false)
})
})
describe('.eml-Datei', () => {
it('trägt Empfänger, Betreff und den vollen Text', () => {
const eml = emlErzeugen({
an: 'service@allianz.de',
betreff: 'Testbetreff',
text: 'Zeile eins.\nZeile zwei.',
}).toString('utf8')
expect(eml).toContain('To: service@allianz.de')
expect(eml).toContain('Subject: Testbetreff')
expect(eml).toContain('Zeile eins.')
expect(eml).toContain('Zeile zwei.')
})
it('bittet das Mailprogramm um einen Entwurf', () => {
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'B', text: 'T' }).toString('utf8')
expect(eml).toContain('X-Unsent: 1')
})
it('erfindet keinen Absender', () => {
// Ein erfundener From-Kopf würde je nach Programm übernommen - und
// dann ginge die Mail mit falschem Absender hinaus.
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'B', text: 'T' }).toString('utf8')
expect(eml).not.toMatch(/^From:/m)
})
it('kodiert Umlaute im Betreff nach RFC 2047', () => {
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'Rückkaufswert', text: 'T' }).toString('utf8')
expect(eml).toContain('Subject: =?UTF-8?B?')
// Und zurückgerechnet stimmt der Inhalt.
const treffer = eml.match(/Subject: =\?UTF-8\?B\?([A-Za-z0-9+/=]+)\?=/)
expect(Buffer.from(treffer![1]!, 'base64').toString('utf8')).toBe('Rückkaufswert')
})
it('lässt einen ASCII-Betreff lesbar', () => {
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'Anfrage', text: 'T' }).toString('utf8')
expect(eml).toContain('Subject: Anfrage')
})
})
describe('quoted-printable', () => {
it('kodiert Umlaute und lässt ASCII lesbar', () => {
const kodiert = quotedPrintable('Grüße')
expect(kodiert).toContain('Gr')
expect(kodiert).toContain('=C3=BC')
expect(kodiert).toContain('=C3=9F')
})
it('kodiert das Gleichheitszeichen', () => {
expect(quotedPrintable('a=b')).toBe('a=3Db')
})
it('hält die 76-Zeichen-Grenze je Zeile ein', () => {
const kodiert = quotedPrintable('ä'.repeat(200))
for (const zeile of kodiert.split('\r\n')) {
expect(zeile.length).toBeLessThanOrEqual(76)
}
})
it('lässt Kopfzeilen ohne Sonderzeichen unkodiert', () => {
expect(kopfzeileKodieren('Anfrage 2026')).toBe('Anfrage 2026')
})
})
+193
View File
@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
MAX_PASSKEYS_JE_BENUTZER,
PASSKEY_MELDUNG,
PasskeyFehler,
challengeAusAntwort,
passkeysMoeglich,
} from '@/lib/auth/passkey'
import { passkeyBrowserfehler } from '@/lib/passkey-browser'
import { env } from '@/lib/env'
/**
* Passkeys — die Teile, die sich ohne Datenbank und ohne Browser prüfen
* lassen.
*
* Der Schwerpunkt liegt auf der Herleitung von `rpId` und Herkunft aus
* `AUTH_URL`. Das ist bei WebAuthn die Stelle, an der eine Installation
* still kaputtgeht: Stimmt die Bereichskennung nicht, lässt sich zwar ein
* Passkey einrichten, aber nie wieder verwenden — und die Fehlermeldung des
* Browsers zeigt auf alles Mögliche, nur nicht auf die Konfiguration.
*
* Dieselbe Fehlerklasse hat uns bei `APP_BIND` und `ENC_KEY_V2` schon zwei
* Fassungen gekostet: eine Umgebungsvariable, die falsch ausgewertet wird,
* und ein Fehlerbild, das woandershin zeigt.
*/
let vorher: string | undefined
beforeEach(() => {
vorher = process.env.AUTH_URL
})
afterEach(() => {
if (vorher === undefined) delete process.env.AUTH_URL
else process.env.AUTH_URL = vorher
})
describe('Bereichskennung aus AUTH_URL', () => {
it('nimmt den nackten Hostnamen als rpId, ohne Schema und ohne Port', () => {
process.env.AUTH_URL = 'https://cockpit.example.de'
expect(env.passkey.rpId).toBe('cockpit.example.de')
expect(env.passkey.herkunft).toBe('https://cockpit.example.de')
})
it('lässt den Port in der Herkunft stehen, aber nicht in der rpId', () => {
// Die Herkunft wird zeichengenau mit dem verglichen, was im Browser in
// der Adresszeile stand - der Port gehoert dazu. Die rpId ist dagegen
// die Domain, an die der Schluessel gebunden wird; ein Port darf dort
// nicht auftauchen, sonst weist der Authenticator sie zurueck.
process.env.AUTH_URL = 'https://cockpit.example.de:8443'
expect(env.passkey.rpId).toBe('cockpit.example.de')
expect(env.passkey.herkunft).toBe('https://cockpit.example.de:8443')
})
it('verträgt einen Schrägstrich am Ende', () => {
process.env.AUTH_URL = 'https://cockpit.example.de/'
expect(env.passkey.herkunft).toBe('https://cockpit.example.de')
})
it('erkennt eine Unterdomain als eigene Kennung', () => {
// Ein Passkey fuer betreuung.example.de gilt NICHT fuer example.de -
// genau das ist der Phishingschutz.
process.env.AUTH_URL = 'https://betreuung.example.de'
expect(env.passkey.rpId).toBe('betreuung.example.de')
})
})
describe('Verfügbarkeit', () => {
it('bietet Passkeys über https an', () => {
process.env.AUTH_URL = 'https://cockpit.example.de'
expect(env.passkey.sicherAufrufbar).toBe(true)
expect(passkeysMoeglich()).toBe(true)
})
it('bietet sie auf localhost an, auch ohne https', () => {
// Der Browser wertet localhost als sicheren Kontext - sonst liesse
// sich in der Entwicklung nichts ausprobieren.
process.env.AUTH_URL = 'http://localhost:3000'
expect(env.passkey.sicherAufrufbar).toBe(true)
})
it('bietet sie NICHT über einfaches http an', () => {
// Wichtig, dass das der Server entscheidet und nicht der Browser: Ein
// Knopf, der beim Klick eine kryptische DOMException wirft, ist
// schlechter als gar kein Knopf.
process.env.AUTH_URL = 'http://cockpit.example.de'
expect(env.passkey.sicherAufrufbar).toBe(false)
expect(passkeysMoeglich()).toBe(false)
})
it('bietet sie auch über eine nackte IP ohne https nicht an', () => {
process.env.AUTH_URL = 'http://10.0.0.5:3000'
expect(env.passkey.sicherAufrufbar).toBe(false)
})
})
describe('Challenge aus der Antwort des Browsers', () => {
function clientData(inhalt: unknown): string {
return Buffer.from(JSON.stringify(inhalt), 'utf8').toString('base64url')
}
it('liest die Challenge aus den clientData', () => {
const daten = clientData({
type: 'webauthn.get',
challenge: 'AbCdEf-1234_xyz',
origin: 'https://cockpit.example.de',
})
expect(challengeAusAntwort(daten)).toBe('AbCdEf-1234_xyz')
})
it('weist eine Antwort ohne Challenge ab', () => {
const daten = clientData({ type: 'webauthn.get', origin: 'https://cockpit.example.de' })
expect(() => challengeAusAntwort(daten)).toThrowError(PasskeyFehler)
})
it('weist eine leere Challenge ab', () => {
expect(() => challengeAusAntwort(clientData({ challenge: '' }))).toThrowError(PasskeyFehler)
})
it('weist eine Challenge ab, die keine Zeichenkette ist', () => {
expect(() => challengeAusAntwort(clientData({ challenge: 42 }))).toThrowError(PasskeyFehler)
})
it('weist Unsinn ab, statt daran abzustürzen', () => {
expect(() => challengeAusAntwort('das ist kein base64url-JSON')).toThrowError(PasskeyFehler)
expect(() => challengeAusAntwort('')).toThrowError(PasskeyFehler)
})
})
describe('Meldungen', () => {
it('hat zu jedem Grund einen Satz, den man jemandem zeigen kann', () => {
// Ein fehlender Eintrag wuerde als "undefined" auf dem Bildschirm
// landen. Der Typ verhindert das beim Uebersetzen; hier wird geprueft,
// dass auch nichts Leeres durchrutscht.
for (const [grund, text] of Object.entries(PASSKEY_MELDUNG)) {
expect(text.length, `Meldung für "${grund}"`).toBeGreaterThan(20)
expect(text, `Meldung für "${grund}"`).not.toMatch(/undefined/)
}
})
it('nennt in der Obergrenze die tatsächliche Zahl', () => {
expect(PASSKEY_MELDUNG['zu-viele']).toContain(String(MAX_PASSKEYS_JE_BENUTZER))
})
it('erklärt bei fehlender Nutzerverifikation, warum abgelehnt wird', () => {
// Das ist die Meldung, die am ehesten nach Schikane aussieht. Sie muss
// den Grund nennen, sonst haelt man sie fuer einen Fehler.
expect(PASSKEY_MELDUNG['nicht-verifiziert']).toMatch(/Fingerabdruck|PIN/)
})
})
describe('Fehler des Browsers', () => {
function dom(name: string): Error {
const fehler = new Error('irgendwas Englisches')
fehler.name = name
return fehler
}
it('deutet NotAllowedError als Abbruch, nicht als Verbot', () => {
// Der Name ist irrefuehrend: Er kommt auch, wenn schlicht niemand den
// Finger aufgelegt hat. "Nicht erlaubt" waere die falsche Auskunft.
const text = passkeyBrowserfehler(dom('NotAllowedError'))
expect(text).toMatch(/Abgebrochen/)
expect(text).not.toMatch(/verboten|nicht erlaubt/i)
})
it('erklärt beim Einrichten, dass dieses Gerät schon einen Passkey hat', () => {
expect(passkeyBrowserfehler(dom('InvalidStateError'), true)).toMatch(/bereits/)
})
it('sagt bei SecurityError, dass die Adresse nicht passt', () => {
expect(passkeyBrowserfehler(dom('SecurityError'))).toMatch(/Adress/)
})
it('fällt bei unbekannten Fehlern auf einen brauchbaren Satz zurück', () => {
expect(passkeyBrowserfehler(dom('VoellingNeuerFehler'))).toMatch(/noch einmal/)
expect(passkeyBrowserfehler('gar kein Fehlerobjekt')).toMatch(/noch einmal/)
expect(passkeyBrowserfehler(undefined)).toMatch(/noch einmal/)
})
it('gibt nie die englische Originalmeldung weiter', () => {
for (const name of [
'NotAllowedError',
'InvalidStateError',
'SecurityError',
'NotSupportedError',
'AbortError',
'Unbekannt',
]) {
expect(passkeyBrowserfehler(dom(name))).not.toContain('irgendwas Englisches')
}
})
})
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
SCHREIBEN_ARTEN,
SCHREIBEN_EIGENSCHAFTEN,
dateinameFuerSchreiben,
istSchreibenArt,
} from '@/lib/schreiben/arten'
import { RUECKKAUFSWERT_WIEDERVORLAGE_TAGE } from '@/lib/vertraege/rueckkaufswert'
import { NACHFASSEN_AB_TAGEN } from '@/lib/vorgaenge/sachstandsanfrage'
/**
* Schreiben erzeugen — die Teile ohne Datenbank.
*
* Der Schwerpunkt liegt auf zwei Dingen, die sonst still auseinanderlaufen:
* den Wiedervorlagefristen (sie stehen an zwei Stellen) und dem Dateinamen
* (er landet im Objektspeicher und im Download).
*/
describe('Arten von Schreiben', () => {
it('erkennt bekannte Arten und weist alles andere ab', () => {
expect(istSchreibenArt('RUECKKAUFSWERT')).toBe(true)
expect(istSchreibenArt('FREI')).toBe(true)
expect(istSchreibenArt('etwas-anderes')).toBe(false)
expect(istSchreibenArt(null)).toBe(false)
expect(istSchreibenArt(undefined)).toBe(false)
})
it('beschreibt jede Art vollständig', () => {
for (const art of SCHREIBEN_ARTEN) {
const eigenschaft = SCHREIBEN_EIGENSCHAFTEN[art]
expect(eigenschaft.beschriftung.length, art).toBeGreaterThan(3)
// Wo es eine Frist gibt, muss auch dastehen, warum sie so lang ist.
if (eigenschaft.wiedervorlageTage !== null) {
expect(eigenschaft.fristBegruendung, art).toBeTruthy()
}
}
})
it('schlägt für ein freies Schreiben KEINE Frist vor', () => {
// Bei einem freien Schreiben weiß niemand, ob und wann eine Antwort
// kommt. Eine erfundene Frist wäre schlechter als keine.
expect(SCHREIBEN_EIGENSCHAFTEN.FREI.wiedervorlageTage).toBeNull()
})
})
describe('Wiedervorlagefristen bleiben mit den Modulen im Gleichschritt', () => {
it('nimmt beim Rückkaufswert dieselbe Zahl wie das Vertragsmodul', () => {
// Sie steht dort für die Anzeige am Vertrag, hier für die erzeugte
// Frist. Laufen sie auseinander, mahnt die Anwendung zu zwei
// verschiedenen Terminen — und man glaubt keinem mehr.
expect(SCHREIBEN_EIGENSCHAFTEN.RUECKKAUFSWERT.wiedervorlageTage).toBe(
RUECKKAUFSWERT_WIEDERVORLAGE_TAGE,
)
})
it('nimmt bei der Sachstandsanfrage dieselbe Zahl wie das Vorgangsmodul', () => {
expect(SCHREIBEN_EIGENSCHAFTEN.SACHSTAND.wiedervorlageTage).toBe(NACHFASSEN_AB_TAGEN)
})
})
describe('Dateiname', () => {
const tag = new Date('2026-08-23T00:00:00.000Z')
it('beginnt mit dem Datum, damit die Dateien sortierbar bleiben', () => {
const name = dateinameFuerSchreiben('KUENDIGUNG', 'Allianz', tag, 'docx')
expect(name.startsWith('2026-08-23_')).toBe(true)
expect(name.endsWith('.docx')).toBe(true)
})
it('nennt Art und Empfänger', () => {
const name = dateinameFuerSchreiben('KUENDIGUNG', 'Allianz', tag, 'pdf')
expect(name).toContain('Kuendigung')
expect(name).toContain('Allianz')
expect(name.endsWith('.pdf')).toBe(true)
})
it('löst Umlaute auf und wirft Sonderzeichen heraus', () => {
// Der Name geht in den Objektspeicher und über einen HTTP-Kopf. Beides
// verträgt keine Umlaute und keine Anführungszeichen.
const name = dateinameFuerSchreiben('FREI', 'Müller & Co. "Süd"', tag, 'docx')
// Nur der Namensteil — der Punkt vor der Endung gehört dazu.
const ohneEndung = name.replace(/\.docx$/, '')
expect(name).not.toMatch(/[^\x20-\x7E]/)
expect(ohneEndung).not.toMatch(/["&.]/)
expect(name).toContain('Mueller')
expect(name).toContain('Sued')
})
it('kommt ohne Empfänger aus', () => {
const name = dateinameFuerSchreiben('FREI', null, tag, 'docx')
expect(name).toBe('2026-08-23_Freies-Schreiben.docx')
})
it('kürzt einen sehr langen Empfänger, statt den Namen ausufern zu lassen', () => {
const lang = 'Sehr Lange Versicherungsgesellschaft auf Gegenseitigkeit in Oberfranken'
const name = dateinameFuerSchreiben('FREI', lang, tag, 'pdf')
expect(name.length).toBeLessThan(90)
})
})
+87
View File
@@ -0,0 +1,87 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
/**
* Die Betriebsskripte müssen im Repository so liegen, dass sie auf dem
* Server auch laufen.
*
* Anlass sind zwei Fehler, die beide erst im Betrieb aufgefallen sind und
* beide nichts mit dem Programm zu tun hatten, sondern mit der Verpackung:
*
* 1. `scripts/` fehlte im Docker-Abbild — `node scripts/totp-geheimnis.mjs`
* lief ins Leere (behoben in 1.0.1).
* 2. Die `.sh`-Dateien lagen mit Modus 644 im Repository. Die Anleitung
* sagt `./scripts/update.sh`, auf Linux kam „Permission denied".
*
* Beide Male hat kein Test angeschlagen, weil beide Male der Code richtig
* war. Diese Datei prüft deshalb nicht Verhalten, sondern Verpackung.
*
* Der Ausführbarkeitsbit wird aus dem Git-Index gelesen, nicht vom
* Dateisystem: Unter Windows steht `core.filemode=false`, dort sagt das
* Dateisystem nichts aus. Maßgeblich ist, was ausgecheckt wird.
*/
function gitVerfuegbar(): boolean {
try {
execFileSync('git', ['rev-parse', '--git-dir'], { stdio: 'pipe' })
return true
} catch {
return false
}
}
/** Liefert [Modus, Pfad] je Datei, so wie sie im Index steht. */
function eintraegeImIndex(muster: string): [string, string][] {
const ausgabe = execFileSync('git', ['ls-files', '-s', muster], { encoding: 'utf8' })
return ausgabe
.split('\n')
.filter((zeile) => zeile.trim() !== '')
.map((zeile) => {
const [angaben, pfad] = zeile.split('\t')
return [angaben!.split(' ')[0]!, pfad!]
})
}
const mitGit = gitVerfuegbar()
describe.skipIf(!mitGit)('Betriebsskripte', () => {
it('legt jedes Shell-Skript ausführbar ab', () => {
const skripte = eintraegeImIndex('scripts/*.sh')
// Wenn hier nichts steht, stimmt das Muster nicht mehr - das waere
// ein stiller Ausfall der Pruefung.
expect(skripte.length).toBeGreaterThan(0)
for (const [modus, pfad] of skripte) {
expect(modus, `${pfad} muss ausführbar sein (100755, ist ${modus})`).toBe('100755')
}
})
it('gibt jedem Shell-Skript eine Shebang-Zeile', () => {
for (const [, pfad] of eintraegeImIndex('scripts/*.sh')) {
const ersteZeile = readFileSync(pfad, 'utf8').split('\n')[0] ?? ''
expect(ersteZeile, `${pfad} braucht eine Shebang-Zeile`).toMatch(/^#!/)
}
})
it('nimmt scripts/ ins Docker-Abbild auf', () => {
// Der Fehler aus 1.0.1: Die Wartungsskripte lagen im Repository, aber
// nicht im Behaelter. Aufgefallen ist es erst, als sie gebraucht wurden.
const dockerfile = readFileSync('Dockerfile', 'utf8')
// Die Zeile traegt zusaetzliche Schalter (--from, --chown) - deshalb
// nicht auf eine feste Reihenfolge festlegen, sondern nur darauf, dass
// scripts/ ins Abbild kopiert wird.
expect(dockerfile).toMatch(/^COPY.*\/scripts\s+\.\/scripts\s*$/m)
})
it('bindet die Sicherungsskripte aus scripts/ in die Compose-Datei ein', () => {
// Sie werden als Volume in den Sicherungsdienst gehaengt. Ein
// umbenanntes Skript faellt sonst erst um 03:15 Uhr auf - im Log,
// das niemand liest.
const compose = readFileSync('docker-compose.yml', 'utf8')
for (const name of ['sicherung.sh', 'wiederherstellung.sh']) {
expect(compose, `${name} wird nicht eingebunden`).toContain(`./scripts/${name}:`)
}
})
})
+395
View File
@@ -0,0 +1,395 @@
import { describe, expect, it } from 'vitest'
import {
EINZELZAHLUNG_AB_CENT,
MINDESTANZAHL,
abstandInTagen,
besteSchreibweise,
betraegeSchwanken,
einzelneZahlungen,
gruppieren,
normalisieren,
eindeutigerVertrag,
offeneKandidaten,
rhythmusAusAbstand,
rhythmusErklaerung,
type ZahlungEingabe,
} from '@/lib/finanzen/wiederkehrend'
/**
* Wiederkehrende Zahlungen aufspüren.
*
* Der teuerste Fehler dieser Funktion ist nicht eine übersehene Zahlung —
* die sieht man beim Durchgehen der Liste. Es ist eine **falsch
* zusammengefasste Gruppe**: Dann verschwindet ein Vertrag hinter einem
* fremden Namen, und im Vermögensverzeichnis fehlt er.
*
* Die Prüfungen sind deshalb in beide Richtungen gebaut: Was
* zusammengehört, muss zusammenfallen — und was nur ähnlich aussieht, muss
* getrennt bleiben.
*/
function zahlung(
gegenstelle: string | null,
datum: string,
euro: number,
id = `${gegenstelle}-${datum}`,
): ZahlungEingabe {
return {
id,
gegenstelle,
datum: new Date(`${datum}T00:00:00.000Z`),
betragCent: BigInt(Math.round(euro * 100)),
}
}
/** Eine Reihe monatlicher Abbuchungen, jeweils am Monatsersten. */
function monatlich(gegenstelle: string, euro: number, monate: number, jahr = 2026) {
return Array.from({ length: monate }, (_, i) =>
zahlung(
gegenstelle,
`${jahr}-${String(i + 1).padStart(2, '0')}-01`,
euro,
`${gegenstelle}-${i}`,
),
)
}
describe('Schreibweisen vergleichbar machen', () => {
it('fasst Groß- und Kleinschreibung, Satzzeichen und Rechtsform zusammen', () => {
const erwartet = normalisieren('Allianz Lebensversicherung AG')
expect(normalisieren('ALLIANZ LEBENSVERSICHERUNG AG')).toBe(erwartet)
expect(normalisieren('allianz lebensversicherung ag')).toBe(erwartet)
expect(normalisieren('Allianz Lebensversicherung-AG')).toBe(erwartet)
})
it('löst Umlaute auf', () => {
expect(normalisieren('Münchener Verein')).toBe(normalisieren('Muenchener Verein'))
expect(normalisieren('Süddeutsche Krankenversicherung')).toBe(
normalisieren('Sueddeutsche Krankenversicherung'),
)
})
it('entfernt die Rechtsform nur am Ende, nicht mitten im Namen', () => {
// "ag" steckt in "Agentur" - wer stumpf ersetzt, macht daraus "entur".
expect(normalisieren('Agentur für Arbeit')).toContain('agentur')
expect(normalisieren('Debeka AG')).toBe(normalisieren('Debeka'))
})
it('entfernt auch mehrteilige Rechtsformen', () => {
expect(normalisieren('Stadtwerke Bamberg GmbH & Co KG')).toBe(
normalisieren('Stadtwerke Bamberg'),
)
})
it('hält verschiedene Empfänger auseinander, auch bei ähnlichem Namen', () => {
// Der wichtigste Test der Datei. Drei Zeichen Unterschied, zwei völlig
// verschiedene Rechnungssteller.
expect(normalisieren('Stadtwerke Bamberg')).not.toBe(normalisieren('Stadtwerke Bayreuth'))
// Und: "Versicherung" ist kein wegwerfbarer Zusatz.
expect(normalisieren('Allianz Versicherung')).not.toBe(
normalisieren('Allianz Lebensversicherung'),
)
})
it('verträgt leere Angaben', () => {
expect(normalisieren(null)).toBe('')
expect(normalisieren(undefined)).toBe('')
expect(normalisieren(' ')).toBe('')
})
})
describe('Welche Schreibweise angezeigt wird', () => {
it('zieht die gemischte Schreibweise der geschrienen vor', () => {
// Nicht Kosmetik: Dieser Name landet beim „Vertrag anlegen" im Feld
// Gesellschaft. Steht dort alles in Versalien, tippt man es neu — und
// dann war die Vorbelegung wertlos.
expect(
besteSchreibweise(['ALLIANZ LEBENSVERSICHERUNG AG', 'Allianz Lebensvers. AG']),
).toBe('Allianz Lebensvers. AG')
})
it('nimmt unter mehreren gemischten die längste', () => {
expect(besteSchreibweise(['Allianz', 'Allianz Lebensversicherung AG'])).toBe(
'Allianz Lebensversicherung AG',
)
})
it('nimmt die längste geschriene, wenn es nichts anderes gibt', () => {
expect(besteSchreibweise(['ALLIANZ AG', 'ALLIANZ LEBENSVERSICHERUNG AG'])).toBe(
'ALLIANZ LEBENSVERSICHERUNG AG',
)
})
it('verträgt eine leere Liste', () => {
expect(besteSchreibweise([])).toBe('')
})
})
describe('Rhythmus', () => {
it('erkennt monatlich trotz unterschiedlich langer Monate', () => {
// Februar auf März sind 28 Tage, Juli auf August 31 - beides monatlich.
expect(rhythmusAusAbstand(28)).toBe('MONATLICH')
expect(rhythmusAusAbstand(31)).toBe('MONATLICH')
expect(rhythmusAusAbstand(30)).toBe('MONATLICH')
})
it('erkennt die übrigen üblichen Rhythmen', () => {
expect(rhythmusAusAbstand(61)).toBe('ZWEIMONATLICH')
expect(rhythmusAusAbstand(91)).toBe('VIERTELJAEHRLICH')
expect(rhythmusAusAbstand(182)).toBe('HALBJAEHRLICH')
expect(rhythmusAusAbstand(365)).toBe('JAEHRLICH')
})
it('nennt Unpassendes unregelmäßig, statt es zu erzwingen', () => {
expect(rhythmusAusAbstand(3)).toBe('UNREGELMAESSIG')
expect(rhythmusAusAbstand(45)).toBe('UNREGELMAESSIG')
expect(rhythmusAusAbstand(250)).toBe('UNREGELMAESSIG')
})
it('nimmt den Median, nicht den Durchschnitt', () => {
// Vier monatliche Zahlungen, dann eine Lücke von einem Jahr. Der
// Durchschnitt läge bei über 100 Tagen und ergäbe Unsinn.
const daten = [
new Date('2026-01-01T00:00:00Z'),
new Date('2026-02-01T00:00:00Z'),
new Date('2026-03-01T00:00:00Z'),
new Date('2026-04-01T00:00:00Z'),
new Date('2027-04-01T00:00:00Z'),
]
const abstand = abstandInTagen(daten)
expect(abstand).not.toBeNull()
expect(rhythmusAusAbstand(abstand!)).toBe('MONATLICH')
})
it('liefert ohne zweite Zahlung keinen Abstand', () => {
expect(abstandInTagen([new Date('2026-01-01T00:00:00Z')])).toBeNull()
expect(abstandInTagen([])).toBeNull()
})
})
describe('Schwankende Beträge', () => {
it('lässt eine Beitragsanpassung durchgehen', () => {
expect(betraegeSchwanken([4250n, 4250n, 4390n])).toBe(false)
})
it('meldet einen Stromabschlag, der nach Verbrauch schwankt', () => {
expect(betraegeSchwanken([8000n, 12000n, 4000n])).toBe(true)
})
it('meldet nichts bei einer einzelnen Zahlung', () => {
expect(betraegeSchwanken([4250n])).toBe(false)
})
})
describe('Gruppieren', () => {
it('fasst monatliche Abbuchungen zu einer Gruppe zusammen', () => {
const gruppen = gruppieren(monatlich('Allianz Lebensversicherung AG', 42.5, 6))
expect(gruppen).toHaveLength(1)
const gruppe = gruppen[0]!
expect(gruppe.anzahl).toBe(6)
expect(gruppe.rhythmus).toBe('MONATLICH')
expect(gruppe.betragCent).toBe(4250n)
// Sechs Monate à 42,50 EUR sind hochgerechnet 510 EUR im Jahr.
expect(gruppe.jahresbetragCent).toBe(51_000n)
expect(gruppe.betragSchwankt).toBe(false)
})
it('fasst verschiedene Schreibweisen derselben Stelle zusammen', () => {
const gruppen = gruppieren([
zahlung('Allianz Lebensversicherung AG', '2026-01-01', 42.5, 'a'),
zahlung('ALLIANZ LEBENSVERSICHERUNG AG', '2026-02-01', 42.5, 'b'),
zahlung('allianz lebensversicherung', '2026-03-01', 42.5, 'c'),
])
expect(gruppen).toHaveLength(1)
expect(gruppen[0]!.anzahl).toBe(3)
// Alle Schreibweisen bleiben sichtbar - man soll nachvollziehen können,
// was zusammengeworfen wurde.
expect(gruppen[0]!.schreibweisen).toHaveLength(3)
})
it('zeigt die lesbare Schreibweise an, nicht die geschriene', () => {
const gruppen = gruppieren([
zahlung('Prüfhausen Lebensversicherung AG', '2026-01-01', 42.5, 'a'),
zahlung('PRUEFHAUSEN LEBENSVERSICHERUNG AG', '2026-02-01', 42.5, 'b'),
])
expect(gruppen[0]!.anzeigename).toBe('Prüfhausen Lebensversicherung AG')
})
it('hält verschiedene Empfänger getrennt', () => {
const gruppen = gruppieren([
...monatlich('Stadtwerke Bamberg', 80, 3),
...monatlich('Stadtwerke Bayreuth', 80, 3),
])
expect(gruppen).toHaveLength(2)
})
it('übergeht eine einzelne Zahlung', () => {
const gruppen = gruppieren([zahlung('Irgendwer', '2026-01-01', 42.5)])
expect(gruppen).toHaveLength(0)
expect(MINDESTANZAHL).toBe(2)
})
it('übergeht Buchungen ohne Gegenstelle', () => {
// Bargeldabhebungen und handerfasste Zeilen - kein Vertragshinweis.
const gruppen = gruppieren([
zahlung(null, '2026-01-01', 100, 'a'),
zahlung(null, '2026-02-01', 100, 'b'),
zahlung('', '2026-03-01', 100, 'c'),
])
expect(gruppen).toHaveLength(0)
})
it('sortiert nach Jahresbetrag, nicht nach Anzahl', () => {
const gruppen = gruppieren([
...monatlich('Kleiner Beitrag', 5, 12),
// Zwei Zahlungen im Jahresabstand à 1200 EUR.
zahlung('Große Police', '2025-06-01', 1200, 'g1'),
zahlung('Große Police', '2026-06-01', 1200, 'g2'),
])
expect(gruppen[0]!.anzeigename).toBe('Große Police')
expect(gruppen[0]!.rhythmus).toBe('JAEHRLICH')
expect(gruppen[1]!.anzeigename).toBe('Kleiner Beitrag')
})
it('erkennt einen hinterlegten Vertrag', () => {
const gruppen = gruppieren(monatlich('Debeka AG', 30, 4), {
vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka' }],
})
expect(gruppen[0]!.vertragId).toBe('v1')
expect(gruppen[0]!.vertragBezeichnung).toBe('Krankenzusatz')
})
it('lässt abgehakte Gruppen verschwinden', () => {
const abgehakt = new Set([normalisieren('Rundfunk ARD ZDF')])
const gruppen = gruppieren(monatlich('Rundfunk ARD ZDF', 18.36, 6), { abgehakt })
expect(gruppen).toHaveLength(0)
})
it('rechnet unregelmäßige Zahlungen nicht hoch', () => {
// Ohne erkennbaren Rhythmus wäre jede Hochrechnung geraten. Dann steht
// dort, was tatsächlich geflossen ist.
const gruppen = gruppieren([
zahlung('Wechselnd', '2026-01-01', 100, 'a'),
zahlung('Wechselnd', '2026-01-08', 100, 'b'),
zahlung('Wechselnd', '2026-01-20', 100, 'c'),
])
expect(gruppen[0]!.rhythmus).toBe('UNREGELMAESSIG')
expect(gruppen[0]!.jahresbetragCent).toBe(30_000n)
})
it('sagt, wie viele Zahlungen der Gruppe schon an einem Vertrag hängen', () => {
// Grundlage der Sammelzuordnung (Issue #11): Nur der Rest muss noch
// verknüpft werden, nicht alles noch einmal.
const zahlungen = monatlich('Debeka AG', 30, 4)
zahlungen[0]!.vertragId = 'v1'
zahlungen[1]!.vertragId = 'v1'
const gruppe = gruppieren(zahlungen, {
vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka' }],
})[0]!
expect(gruppe.anzahl).toBe(4)
expect(gruppe.bereitsZugeordnet).toBe(2)
expect(gruppe.offeneBuchungIds).toHaveLength(2)
})
it('zählt für das Dashboard nur ungeklärte Gruppen', () => {
const gruppen = gruppieren(
[...monatlich('Debeka AG', 30, 4), ...monatlich('Unbekannt Versicherung', 20, 4)],
{ vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka' }] },
)
expect(gruppen).toHaveLength(2)
expect(offeneKandidaten(gruppen)).toBe(1)
})
})
describe('Einzelne größere Zahlungen', () => {
it('findet die jährliche Prämie, die nur einmal im Auszug steht', () => {
// Die eigentliche Lücke der Gruppierung: Bei einem importierten Jahr
// erscheint ein Jahresbeitrag genau einmal.
const treffer = einzelneZahlungen([zahlung('Feuersozietät', '2026-03-15', 480)])
expect(treffer).toHaveLength(1)
expect(treffer[0]!.betragCent).toBe(48_000n)
})
it('übergeht Kleinbeträge', () => {
expect(einzelneZahlungen([zahlung('Bäckerei', '2026-03-15', 12.4)])).toHaveLength(0)
expect(EINZELZAHLUNG_AB_CENT).toBe(15_000n)
})
it('übergeht, was ohnehin schon als Gruppe erscheint', () => {
const zahlungen = monatlich('Allianz', 200, 3)
expect(gruppieren(zahlungen)).toHaveLength(1)
expect(einzelneZahlungen(zahlungen)).toHaveLength(0)
})
it('übergeht Abgehaktes', () => {
const abgehakt = new Set([normalisieren('Finanzamt Coburg')])
expect(
einzelneZahlungen([zahlung('Finanzamt Coburg', '2026-03-15', 900)], { abgehakt }),
).toHaveLength(0)
})
it('nennt den Vertrag, wenn es schon einen gibt', () => {
const treffer = einzelneZahlungen([zahlung('Debeka', '2026-03-15', 480)], {
vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka AG' }],
})
expect(treffer[0]!.vertragId).toBe('v1')
})
})
describe('Erklärung unter der Gruppe', () => {
it('nennt die Grundlage, nicht nur das Ergebnis', () => {
const gruppe = gruppieren(monatlich('Allianz', 42.5, 4))[0]!
expect(rhythmusErklaerung(gruppe)).toBe('4 Zahlungen, etwa monatlich')
})
it('formuliert größere Abstände in Monaten', () => {
const gruppe = gruppieren([
zahlung('Quartalsweise', '2026-01-01', 100, 'a'),
zahlung('Quartalsweise', '2026-04-01', 100, 'b'),
zahlung('Quartalsweise', '2026-07-01', 100, 'c'),
])[0]!
expect(rhythmusErklaerung(gruppe)).toBe('3 Zahlungen, etwa alle 3 Monate')
})
it('sagt offen, wenn kein Abstand erkennbar ist', () => {
const gruppe = gruppieren([
zahlung('Wechselnd', '2026-01-01', 100, 'a'),
zahlung('Wechselnd', '2026-01-08', 100, 'b'),
])[0]!
expect(rhythmusErklaerung(gruppe)).toContain('ohne erkennbaren Abstand')
})
})
describe('Eindeutige Vertragszuordnung beim Import', () => {
const vertraege = [
{ id: 'v1', bezeichnung: 'Hausrat', gesellschaft: 'Allianz Versicherung' },
{ id: 'v2', bezeichnung: 'Haftpflicht', gesellschaft: 'Allianz Versicherung' },
{ id: 'v3', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka AG' },
]
it('ordnet zu, wenn genau ein Vertrag passt', () => {
expect(eindeutigerVertrag('Debeka', vertraege)?.id).toBe('v3')
})
it('ordnet NICHT zu, wenn zwei Verträge zur selben Gesellschaft gehören', () => {
// Hausrat und Haftpflicht bei derselben Gesellschaft. Eine falsche
// Zuordnung wäre schlimmer als gar keine — sie behauptet einen Beleg,
// und niemand sieht noch einmal hin.
expect(eindeutigerVertrag('Allianz Versicherung', vertraege)).toBeNull()
})
it('ordnet nichts zu, wenn nichts passt', () => {
expect(eindeutigerVertrag('Stadtwerke Bamberg', vertraege)).toBeNull()
expect(eindeutigerVertrag(null, vertraege)).toBeNull()
expect(eindeutigerVertrag('', vertraege)).toBeNull()
})
})