Commit Graph
20 Commits
Author SHA1 Message Date
Florian Wilde 5b54d7bc5f Ersteinrichtung schliessen: Geheimnis und eine Sperre statt zweier Schritte
Die Aktion ist die einzige Stelle der Anwendung, an der jemand ohne
Anmeldung schreiben darf. Zwei Dinge fehlten.

Erstens stand die Seite zwischen dem ersten Start und dem Anlegen des
Zugangs fuer jeden offen: Wer sie in diesem Fenster fand, wurde
Administrator einer Anwendung mit Gesundheits- und Finanzdaten. Das
Fenster ist kurz, aber Zufall ist keine Zugriffskontrolle. Jetzt muss
ERSTEINRICHTUNG_GEHEIMNIS aus der .env mit eingegeben werden; verglichen
wird ueber Hashes in gleichbleibender Zeit, und ein falscher Wert
antwortet mit derselben Mindestdauer wie das Anmeldeformular.

Fehlt der Wert, ist die Einrichtung gesperrt statt offen. Das ist
Absicht: Eine Installation, bei der die Zeile vergessen wurde, soll eine
Meldung zeigen und nicht einrichtbar sein.

Zweitens waren Pruefung und Anlegen zwei Schritte. Beide laufen jetzt in
einer Transaktion, und die Sperrzeile mit fester Id entscheidet - eine
zweite gleichzeitige Anfrage scheitert am Primaerschluessel, bevor ein
zweiter Administrator entsteht.

Die Seite liest die Sperrzeile mit: Lag der erste Zugang im Papierkorb,
stand Schritt 1 sonst wieder offen. Der Seed setzt sie ebenfalls.

Refs #38
2026-08-24 08:03:49 +02:00
Florian Wilde 107406e3c0 Update brach ohne Meldung ab
Beim Sprung von 1.5.0 auf 1.10.1 endete scripts/update.sh mitten in der
Ausgabe. Kein Fehler, keine Zeile, einfach zurueck zur
Eingabeaufforderung.

Ursache war eine abgeschnittene Leitung:

  git log --oneline "HEAD..$ZIEL" | head -15 | sed ...

head schliesst die Leitung nach 15 Zeilen, git log schreibt weiter und
bekommt SIGPIPE (141). Mit "set -o pipefail" gilt die ganze Kette als
gescheitert, "set -e" beendet das Skript - stumm.

Zeitabhaengig, deshalb monatelang unauffaellig: Solange git log fertig
wurde, bevor head zumachte, ging es gut. Die Schwelle liegt exakt bei
15 Commits, und v1.5.0..v1.10.0 waren die ersten 16.

Behoben, indem der Erzeuger selbst begrenzt (git log -15) statt die
Leitung zu kappen. Dieselbe Behandlung fuer die beiden Tag-Listen.

Dieselbe Falle steckte in der Selbstpruefung von sicherung.sh:

  openssl enc -d ... | head -c 5 | grep -q "PGDMP"

Beide Leser schliessen frueh. Bei kleiner Datenbank steht alles im
Puffer und es faellt nicht auf; mit wachsender Datenbank kippt es - und
dann meldet das Skript eine INTAKTE Sicherung als kaputt und verweigert
das Update. Jetzt liest dd eine feste Menge und endet regulaer.

Damit so etwas nicht wieder stumm passiert:
- Eine ERR-Falle nennt Zeilennummer und Exitcode und erklaert bei 141,
  dass es SIGPIPE ist und ein Fehler im Skript, nicht beim Benutzer.
- Ein Test verbietet "| head" in Skripten mit pipefail und nennt die
  Zeile. Gegenprobe gemacht: Er schlaegt an.
- Bei mehr als 15 Commits steht "... und N weitere" darunter, statt sie
  wortlos wegzulassen.

Nachgestellt: der Ablauf von 1.5.0 auf 1.10.1 laeuft durch.
2026-08-24 00:36:08 +02:00
Florian Wilde 18709a8e49 Ein Jahr Kontobewegungen im Beispielfall
Der Demofall brachte Konten und Kontostaende mit, aber keine einzige
Buchung. Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung
wiederkehrender Zahlungen liefen darin also leer - wer die Anwendung zum
ersten Mal ansah, musste erst eine CSV importieren.

Neu sind 107 Buchungen ueber zwoelf Monate, erzeugt aus einem Muster
statt einzeln abgetippt: Rente, Betriebsrente und Pflegekasse als
Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag, Telefon und
Kontofuehrung monatlich, dazu Rundfunkbeitrag und Grundsteuer
vierteljaehrlich sowie eine Handvoll Einzelposten.

Was der Fall damit zeigt:
- Das Heimentgelt steht in drei Schreibweisen auf dem Auszug und wird
  trotzdem als eine monatliche Zahlung gefuehrt.
- Genau eine Buchung hat weder Quittung noch Begruendung. Eine, nicht
  zwanzig: Die Liste soll etwas zu zeigen haben und nicht rot leuchten.
- Die Sterbegeld-Lastschriften bleiben unzugeordnet, damit sich der
  Handgriff "zwoelf Zahlungen als belegt zuordnen" ausprobieren laesst.
  Der Beitrag steigt im Mai von 18,40 auf 19,80 - damit auch die
  Abweichungspruefung sichtbar wird.
- Der Kontostand zum 31.07. wird aus Anfangsstand und Buchungen
  gerechnet, nicht behauptet. Ein Beispielfall, der sich selbst
  widerspricht, waere schlimmer als einer ohne Buchungen.

Zwei Funde beim Bauen:

Der Unique-Index auf (kontoId, importReferenz) hat einen echten Fehler in
den Daten gefangen: Zwei Renten am Monatsersten ergaben dieselbe
Referenz. Genau dafuer ist er da.

"gGmbH" fehlte in der Rechtsformliste. Dadurch galten "Seniorenzentrum
St. Katharina" und "... gGmbH" als zwei Empfaenger - und die Gruppe
zerfiel ausgerechnet in dem Fachgebiet, in dem fast jeder Traeger eine
gGmbH ist. Ergaenzt, mit Test.

Geprueft: ein Einheitstest fuer die Rechtsform, vier End-to-End-Tests am
Beispielfall - Umfang und Zeitraum, Kontostand gegen Buchungssumme, die
erkannten Gruppen und das Kassenbuch.

Refs #22
2026-08-23 21:35:46 +02:00
Florian Wilde 848aaf1b9f Absenderanschrift in jedem Schreiben
Jeder erzeugte Brief ging bisher ohne Ruecksendeadresse hinaus: Im
Absenderblock stand nur der Name, in der Datumszeile fehlte der Ort. Bei
einer Versicherung oder Behoerde wirkt das unserioes und macht es der
Gegenstelle unnoetig schwer zu antworten.

Neu unter "Mein Konto" ein Feld fuer Strasse, PLZ und Ort. Ab da stehen
sie im Absenderblock jedes Schreibens, und die Datumszeile lautet
"Bayreuth, den 23.08.2026".

Die Anschrift haengt am Benutzer, nicht an der betreuten Person: Sie
gehoert zu mir und gilt fuer alle Akten.

Nicht die Logik fehlte, sondern die Datenquelle - absenderBlock() und
datumsZeile() konnten die Felder schon, briefkopfBauen() hat sie mit
einem Kommentar auf null gesetzt, weil es sie im Datenmodell nicht gab.

Aufbau:
- Drei Felder am Benutzer, Migration ueber den Wrapper aus #24. Das war
  sein erster echter Einsatz: erzeugte Datei ohne Handarbeit brauchbar,
  DROP-INDEX-Zeilen bereits heraus.
- Ein Absender-Typ statt eines blossen Namens, durchgereicht bis in den
  Serienlauf - der hat bisher {name} gebaut und haette die Anschrift
  sonst still verloren.
- Zwei Platzhalter fuer eigene Vorlagen: betreuer_anschrift,
  betreuer_ort.

Was fehlt, bleibt weg: Jede der drei Angaben ist einzeln freiwillig. Nur
ein Ort ergibt eine Datumszeile mit Ort und einen Absenderblock, der
weiterhin nur aus dem Namen besteht. Eine halb ausgefuellte Adresse
sieht nach Fehler aus, ein blosser Name nach Absicht.

Geprueft: sieben Einheitstests fuer den Absenderblock inklusive jeder
Teilbefuellung und der Gegenprobe, dass nie die Heimadresse der
betreuten Person im Absender landet; vier End-to-End-Tests vom leeren
Zustand ueber das Speichern bis zum fertigen Brief.

Refs #21
2026-08-23 20:29:17 +02:00
Florian Wilde 9e68d10787 Ansprechpartner am Vorgang hinterlegen
Ein namentlich adressiertes Schreiben landet bei der Person, die den
Vorgang kennt, statt in der Poststelle. Am Vorgang laesst sich jetzt ein
Kontakt aus dem Verzeichnis als Ansprechpartner benennen; die
Sachstandsanfrage redet ihn an, und sein Name steht ueber der Behoerde.

Die Sachstandsanfrage konnte das schon - sie bekam den Namen nur nie
uebergeben. entwurf.ts lud einen der verknuepften Kontakte und verwarf
ihn wieder. Wer angeredet wird, ist jetzt eine Entscheidung und kein
Zufall.

Aufbau:
- Migration: ansprechpartnerId am Vorgang (SetNull - ein Vorgang darf
  nicht daran haengen, dass es den Kontakt noch gibt) und anrede am
  Kontakt.
- Die Anrede als reine Funktion in kontakte/anrede.ts. Geraten wird nie:
  ohne hinterlegte Anrede wird mit vollem Namen angeredet. Ein leerer
  Name mit gesetzter Anrede faellt auf die allgemeine Anrede zurueck -
  "Sehr geehrte Frau ," darf nie herausgehen.
- Der Wechsel des Ansprechpartners steht mit NAMEN im Protokoll. Zwei
  cuids nebeneinander waeren protokolliert, aber nicht nachvollziehbar,
  und nachvollziehbar war die Anforderung.
- Der Ansprechpartner wird gegen die Akte geprueft, bevor er gespeichert
  wird - sein Name steht anschliessend im Briefkopf.

Nebenbei behoben: Der Schreiben-Editor verwarf die empfaengerAnschrift
aus dem Entwurf, sein State startete immer leer. Jetzt kommt die
Anschrift des Ansprechpartners im Formular an.

Geprueft: neun Einheitstests fuer die Anrede, fuenf End-to-End-Tests von
der Auswahl bis zum Protokolleintrag.

Refs #9
2026-08-23 16:31:10 +02:00
Florian Wilde 09a4528c4b Rueckkaufswert am Vertrag sichtbar machen
Am Telefon mit der Gesellschaft braucht man die Zahl dort, wo man sie
gefragt hat. Auf der Vertragsseite steht jetzt oben der zuletzt
mitgeteilte Wert mit Stichtag und Quelle, darunter die Historie; in der
Uebersicht traegt ein Wert, der aelter als ein Jahr ist, ein Abzeichen.

Ohne Migration, und das ist keine Einsparung, sondern der Punkt: Die
Werthistorie gibt es schon als Wertstand am Vermoegensposten, und der
kann auf einen Vertrag zeigen. Ein Feld am Vertrag waere genau die
zweite Wahrheit, die das Issue ausschliesst - dieselbe Zahl steht im
Vermoegensverzeichnis, weil es dieselbe Zahl ist.

Aufbau:
- Die Ein-Jahres-Grenze als eine getestete Regel in
  finanzen/wertalter.ts. Sie stand bisher als "> 365" mitten in der
  Vermoegensseite; jetzt gilt sie am Vertrag genauso.
- wertHistorie() fuehrt die Wertstaende aller mit dem Vertrag
  verknuepften Posten zu einer Reihe zusammen.
- Wo ein Rueckkaufswert zu erwarten waere, aber keiner erfasst ist,
  steht "noch nicht erfasst" statt einer leeren Zelle.

Zwei Fehler dabei behoben:
- Vertrag und Vermoegensposten wurden ueber die ART des Postens
  zusammengefuehrt. Ein von Hand als "Sonstiges" angelegter Posten wurde
  nicht gefunden, und beim ersten Erfassen am Vertrag entstand still ein
  zweiter - Bericht und Vertrag haetten verschiedene Zahlen gezeigt.
  Massgeblich ist jetzt die Verknuepfung.
- Beim Speichern eines Vermoegenspostens wurde geprueft, ob das
  verknuepfte Konto zur betreuten Person gehoert; bei Vertrag und
  Immobilie fehlte die Pruefung, beim Vertragsverweis einer Buchung
  ebenso. Bei mehreren betreuten Personen haette ein manipuliertes
  Formular ueber Aktengrenzen hinweg verknuepfen koennen.

Geprueft: zehn Einheitstests, vier End-to-End-Tests - darunter der neu
eingetragene Wert, der unveraendert im Vermoegensverzeichnis
wiederauftaucht, und der Posten mit abweichender Art.

Refs #8
2026-08-23 15:06:25 +02:00
Florian Wilde f8d7752c27 Eigene Vorlagen fuer Anschreiben
Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal
falsch. Neu unter Einstellungen -> Schreibvorlagen: eigene Texte
hinterlegen, beim freien Schreiben auswaehlen, Platzhalter fuellen sich
aus den Daten, die ohnehin im System stehen.

Aufbau:
- Die Ersetzung als reine Funktionen in schreiben/platzhalter.ts. Drei
  Faelle, keiner davon still: Wert einsetzen, sichtbare Luecke
  [Aktenzeichen des Gerichts], unbekannter Platzhalter markiert. Ein
  Tippfehler in der Vorlage darf das Schreiben nicht verhindern - aber
  er muss ins Auge springen.
- Die Bestellungsabsatz-Warnung ist zweistufig: Der erste Speicherversuch
  ohne ihn kommt mit einer Rueckfrage zurueck, erst die Bestaetigung
  speichert. Ohne den Nachweis der Vertretungsmacht kommt keine Auskunft,
  und das faellt erst nach vier Wochen auf. Ein ausformulierter Absatz
  zaehlt so viel wie der Platzhalter, ein halber Nachweis ("Beschluss der
  Eigentuemerversammlung") nicht.
- Vier mitgelieferte Vorlagen bleiben Code und damit unveraenderlich;
  "Als Kopie uebernehmen" legt eine eigene Fassung an. Fuer Rueckkaufswert
  und Kuendigung fuehrt die Oberflaeche weiter zuerst zu den
  fallbezogenen Erzeugern - die rechnen Kuendigungstermine aus, was eine
  Vorlage nicht kann.
- Vorlagen haengen an der betreuten Person, nicht am Benutzer: Sie
  beziehen sich auf ihren Fall, liegen im Papierkorb wie alles andere und
  sind im Vollexport ihrer Akte enthalten.

Der Editor haelt Betreff und Text als eigenen State. Beim Wechsel der
Vorlage ueberlebte der alte Entwurf die Navigation, die Vorlage kam nie
im Textfeld an - ein key auf dem Editor setzt ihn neu auf. Gefunden hat
das erst der End-to-End-Test.

Geprueft: dreizehn Einheitstests fuer die Ersetzung, sechs
End-to-End-Tests fuer Warnung, Kopie, Aufloesung und Papierkorb.

Refs #7
2026-08-23 13:41:51 +02:00
Florian Wilde 9fe6f10f25 Rueckkaufswerte aller Versicherungen in einem Zug anfragen
Vor Vermoegensverzeichnis und Jahresbericht braucht es von jeder Lebens-
und Rentenversicherung den Rueckkaufswert zum Stichtag - jedes Mal
dieselben fuenf bis zehn Briefe. Neu unter Vertraege: Stichtag waehlen,
Liste pruefen, ein Klick. Heraus kommt ein ZIP mit einer Word-Datei je
Gesellschaft plus Begleitblatt; je Vertrag entstehen nebenbei die
PDF-Fassung in der Akte, die Wiedervorlage nach 28 Tagen und der
Anfragevermerk.

Aufbau:
- Auswahllogik als reine Funktionen in vertraege/serienabfrage.ts:
  Vorauswahl, Hindernisse mit Begruendung statt stummem Ausblenden,
  28-Tage-Sperre im Gleichschritt mit der Wiedervorlage.
- Der Lauf in vertraege/serienlauf.ts: je Vertrag das volle Paket aus
  Issue #4, aber in einem Durchgang. Kein Alles-oder-nichts - ein
  gescheiterter Vertrag steht mit Grund im Begleitblatt, die uebrigen
  laufen durch. Eine Transaktion ueber alle waere die falsche Zusage.
- Dublettenschutz tagesgenau: Der Doppelklick erzeugt nichts Doppeltes,
  die bewusste zweite Anfrage nach einer Woche bleibt moeglich.
- Die Anzahl steht vor dem Klick auf dem Knopf.

Gekuendigte Vertraege bleiben anfragbar: Bis zum Wirksamwerden besteht
der Vertrag, und genau dann will der Sozialhilfetraeger den Wert sehen.

Geprueft: zwoelf Einheitstests, drei End-to-End-Tests inklusive der
Nebenwirkungen in der Datenbank und des Dublettenschutzes. 602
Unit-Tests, 87 e2e, null Typfehler.

Refs #6
2026-08-23 12:10:32 +02: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 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 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 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 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
Florian Wilde 42eb242767 feat: Update-Skript, das die vergessenen Schritte mitnimmt
Bisher war Aktualisieren eine Folge von Befehlen aus der Anleitung, und
zwei davon vergisst man zuverlaessig: die Sicherung vorher und die beiden
SQL-Skripte hinterher. Genau die hat es hier schon gekostet.

./scripts/update.sh macht das in einem Durchgang - mit zwei
Eigenschaften, auf die es ankommt:

Bis zur Rueckfrage wird nichts veraendert. Der erste Entwurf hat die
eigenen Aenderungen beiseite gelegt und danach am Serverzugriff
abgebrochen - Halbzustand, aus dem man erst wieder herausfinden muss.
Jetzt laufen Pruefung, Abgleich und Changelog-Anzeige vollstaendig
durch, bevor irgendetwas angefasst wird.

Eigene Aenderungen an versionierten Dateien blockieren nicht. Im Betrieb
passt man die docker-compose.yml an, bis etwas laeuft; genau diese
Anpassungen sind spaeter oft schon eingearbeitet. Das Skript legt sie mit
git stash beiseite - rueckholbar, nicht weggeworfen - und sagt, wie man
sie zurueckbekommt. Wer sie wirklich loswerden will, nimmt --verwerfen.
Die .env bleibt immer unangetastet.

Schlaegt die Sicherung fehl, wird nicht aktualisiert. Schlaegt der Bau
fehl, nennt das Skript den Weg zurueck.

Nebenbei behoben: Der Port der MinIO-Konsole stand fest auf 9001. Lief
dort schon etwas, startete der ganze Stapel nicht. Jetzt ueber
MINIO_KONSOLE_PORT einstellbar - dieselbe Moeglichkeit, die APP_PORT
schon hatte. Aufgefallen beim Aufsetzen einer Testumgebung.

Etappe: 8
Phase: Haertung
2026-08-22 15:09:01 +02:00
Florian Wilde 9365645f72 fix: Bindeadresse war in der Vorlage stillschweigend vorbelegt
.env.example setzte APP_BIND=127.0.0.1 als fertigen Wert, mitten zwischen
lauter anderen fertigen Werten. Wer die Datei kopiert und seinen Reverse
Proxy auf einem anderen Server betreibt, kommt damit nicht an die
Anwendung heran - und der Fehler sieht aus, als laege es am Proxy.

Der sichere Standard bleibt 127.0.0.1. Er wird nur nicht mehr als
"schon erledigt" praesentiert, sondern als Entscheidung mit drei
benannten Faellen: Proxy daneben, Proxy woanders mit privatem Netz,
Proxy woanders ohne. Beim dritten Fall steht die Firewallregel dabei.

Die Installationsanleitung nennt APP_BIND jetzt schon in Schritt 3, wo
die .env ausgefuellt wird - nicht erst in Schritt 7, wo der Proxy
drankommt. Wer der Anleitung von oben nach unten folgt, stolpert sonst
erst vier Schritte spaeter darueber.

Dazu ein eigener Abschnitt in der Fehlersuche mit den zwei curl-Proben,
die den Unterschied zwischen falscher Bindeadresse und Firewall zeigen -
und dem Hinweis, dass dafuer kein Neubau noetig ist.

Am Programm aendert sich nichts.

Etappe: 8
Phase: Haertung
2026-08-22 14:22:38 +02:00
Florian Wilde 7633603a2a docs: Nginx Proxy Manager als erste Proxy-Variante
Die Werte, die in der Oberflaeche einzutragen sind, Feld fuer Feld -
Details, SSL und Advanced. Steht vorn, weil es der Fall ist, der hier
tatsaechlich laeuft.

Drei Punkte, die man in der Oberflaeche nicht sieht und die sonst Zeit
kosten:

- Scheme ist http, nicht https. Die Anwendung beendet kein TLS.
- HSTS bleibt in NPM aus - die Anwendung setzt den Kopf bereits selbst,
  sonst geht er zweimal raus.
- Die vier proxy_set_header gehoeren NICHT ins Advanced-Feld. NPM setzt
  sie schon, und zwar im location-Block; ein zweiter Satz laege auf
  server-Ebene und wuerde ueberschrieben. Wer sie trotzdem eintraegt,
  sucht spaeter an der falschen Stelle.

Ins Advanced-Feld gehoeren nur die Werte, die die Oberflaeche nicht
abbildet: Uploadgroesse, Zeitgrenzen und das Abschalten der Pufferung
fuer den Vollexport.

Etappe: 8
Phase: Haertung
2026-08-22 11:27:43 +02:00
Florian Wilde c35fc16133 docs: Installationsanleitung fuer vorhandenes Docker und externen Proxy
Die Anleitung setzt jetzt voraus, was ohnehin da ist: ein Server mit
laufendem Docker und ein Reverse Proxy, der schon steht - auch auf einem
anderen Rechner. Docker-Installation und Betriebssystemhaertung sind
raus, dafuer ist der Proxy-Abschnitt der ausfuehrlichste geworden.

Damit ein externer Proxy die Anwendung ueberhaupt erreicht, war eine
Aenderung noetig: Die Bindeadresse stand fest auf 127.0.0.1. Jetzt gibt
es APP_BIND, Standard bleibt die Loopback-Adresse.

Der Weg dorthin ist nach Vorzug sortiert - privates Netz, dann
WireGuard, dann als Notloesung oeffentliche Adresse mit Firewallregel.
Zwischen Proxy und Anwendung laeuft unverschluesseltes HTTP; das gehoert
nicht ins offene Netz. Deshalb steht in der Kontrolle eine Gegenprobe,
die fehlschlagen MUSS: Antwortet der Anwendungsport von aussen, ist die
Firewallregel falsch.

Fertige Konfigurationen fuer Caddy, nginx und Traefik, jeweils mit den
Werten, die sonst uebersehen werden:

- Zeitgrenze 10 Minuten - sonst brechen Belegmappe, Rechnungslegung und
  Vollexport mitten im Erzeugen ab. Die Standardwerte von nginx liegen
  bei 60 Sekunden.
- Antwort nicht puffern - der Vollexport kommt als Strom. Puffert der
  Proxy, wartet der Browser, bis alles im Speicher des Proxys liegt, und
  laeuft dabei in die Zeitgrenze.
- Und der Hinweis, dass der Proxy KEINE eigene CSP setzen darf: Sie
  enthaelt eine Nonce, die pro Anfrage neu ist. Eine feste CSP blockiert
  die eigenen Skripte - die Seite laedt dann, laesst sich aber nicht
  bedienen.

Der A-Record zeigt auf den Proxy-Server, nicht auf den Anwendungsserver.
Stand vorher falsch da.

Etappe: 8
Phase: Haertung
2026-08-22 11:00:29 +02:00
Florian Wilde 89bc5fe5a8 docs: Installationsanleitung Schritt fuer Schritt
Die README hatte Installationsabschnitte, aber keine durchgehende
Anleitung fuer jemanden, der vor einem frischen Server sitzt. Genau da
faengt diese hier an.

Dreizehn Schritte vom leeren Server bis zur laufenden Anwendung, jeder
mit einer Kontrolle am Ende - man geht nicht weiter, ohne geprueft zu
haben, ob der letzte Schritt geklappt hat. Am Ende eine Abnahmeliste
zum Abhaken, bevor echte Daten hineinkommen.

Aufgenommen sind die Fallstricke, die tatsaechlich Zeit kosten:

- AV-Vertrag klaeren, BEVOR der Server bestellt wird
- Zeitzone setzen, sonst rechnet der Server in UTC und verschiebt Fristen
- AUTH_URL muss exakt der aufgerufenen Adresse entsprechen - sonst landet
  die Anmeldung stumm wieder auf dem Formular, ohne Fehlermeldung. Das
  ist der Fehler, den man am laengsten sucht.
- Der Proxy muss X-Forwarded-For weiterreichen, sonst zaehlt die
  Login-Sperre alle Zugriffe als denselben Anschluss.
- Die beiden SQL-Skripte laufen nicht mit den Migrationen mit.

Zwei Kontrollen sind keine Formalie, sondern Nachweise: Der Schutz des
Audit-Logs wird geprueft, indem ein UPDATE ausdruecklich fehlschlagen
MUSS. Und die Sicherung wird nicht nur angestossen, sondern einmal
zurueckgespielt - solange noch nichts drinsteht, was weh tut.

Der letzte Schritt bringt ENC_KEY_V1 in Sicherheit, an zwei Orte, von
denen keiner der Server ist. Ein gestohlenes Backup ist ohne ihn
wertlos - und ein verlorener Schluessel macht auch das eigene Backup
wertlos. Das ist dieselbe Eigenschaft von zwei Seiten.

Etappe: 8
Phase: Haertung
2026-08-22 10:54:21 +02:00