Phase 9: Kassenverwaltung

Kassenbuch, Mitgliedsbeitraege, Rechnungen an Dritte, Kassenpruefung und
die Auswertungen fuer die Jahreshauptversammlung.

Die drei Festlegungen aus Phase-9.md sitzen in der Datenbank, nicht im
Router: eine festgeschriebene Buchung ist unveraenderbar, ein geprueftes
Jahr nimmt kein Datum mehr an, und gerechnet wird mit Prisma.Decimal.
Korrigiert wird ueber eine Gegenbuchung mit Verweis - beide Buchungen
bleiben stehen, damit im Kassenbuch zu sehen ist, dass etwas berichtigt
wurde.

Neun Modelle, drei Trigger, zwoelf CHECKs, sechs Router, fuenf Ansichten.
Dazu der Bankimport fuer CAMT.053 und MT940 ohne Fremdbibliothek, der
Kategorievorschlag aus dem eigenen Buchungsbestand und die Bruecke von
Phase 8: aus einem gegengezeichneten Beleg wird eine Buchung, das Foto
haengt danach an ihr.

Nebenbei behoben: die Rechnungsnummer war ein Wettlauf, und eine
Organisation liess sich nicht mehr loeschen, sobald sie eine
festgeschriebene Buchung hatte - der Trigger kannte den
Aufbewahrungsschalter nicht.

562 Unit-Tests und 239 E2E-Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 19:23:34 +02:00
co-authored by Claude Opus 5
parent d70acad4cd
commit 4165334c5a
78 changed files with 12314 additions and 42 deletions
+105 -1
View File
@@ -21,7 +21,111 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
| Phase 8 — Lokale KI | 3:40 h | 1 | 04.09.2026 |
| Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 |
| **Summe** | **24:20 h** | **11** | |
| Phase 9 — Kassenverwaltung | 4:10 h | 1 | 04.09.2026 |
| **Summe** | **28:30 h** | **12** | |
---
## 2026-09-04 · Phase 9 — Kassenverwaltung
**12:40 – 16:50 · 4:10 h · Version 0.10.0**
### Was entstanden ist
Neun Modelle, drei Trigger, zwölf CHECKs, sechs Router, fünf Ansichten und
ein Kassenjahr im Seed.
Der Reihe nach, wie es CLAUDE.md vorgibt: Schema, Migration, Serverlogik, UI,
Tests.
| Bereich | Umfang |
|---|---|
| Datenmodell | `Kassenkonto`, `Buchungskategorie`, `Buchung`, `Beitragssatz`, `Beitrag`, `Rechnung`, `Rechnungsposition`, `Kassenabschluss`, `Abschlussposten` |
| Absicherung | RLS für alle neun Tabellen, drei Trigger, zwölf CHECKs, ein REVOKE |
| Bibliothek | `lib/kasse/betrag.ts`, `bankimport.ts`, `vorschlag.ts`, `texte.ts`, `dokumente.ts` |
| Router | Konten, Kategorien, Buchungen, Beiträge, Rechnungen, Abschluss |
| Oberfläche | Übersicht, Kassenbuch, Beiträge, Rechnungen, Abschluss mit Auswertung |
| Hintergrund | Rechnungs-PDF, Kassenprüfbericht, Meldung überfälliger Posten |
| Tests | 67 Unit-Tests (13 gegen eine echte Datenbank), 13 E2E-Tests |
### Entscheidungen
**Die Sperren sitzen in der Datenbank, nicht im Router.** Die ganze Phase
hängt an einem Satz: was geprüft ist, ändert sich nicht mehr. Ein
Zustandscheck in `buchung.aendern` erfüllt das nicht — er gilt für genau
diesen einen Weg. Ein zweiter Router, ein Skript, ein Import, und der geprüfte
Bestand ist ein anderer, ohne dass irgendwo etwas auffällt. Also drei Trigger:
festgeschriebene Buchung unveränderbar, geprüfter Zeitraum zu, festgeschriebener
Abschluss fest.
**Jeder Trigger hat genau eine Ausnahme, und jede hat einen Grund.** Die
Buchung lässt den Verweis auf ihre Gegenbuchung eintragen — eine Stornierung
entsteht später, sie einzutragen ist keine Änderung des Vorgangs, sondern
seine Fortschreibung. Der Abschluss lässt den Prüfbericht nachtragen, denn das
PDF entsteht im Hintergrund und damit nach dem Festschreiben. Ohne diese
Ausnahme gäbe es nie einen Bericht.
**Der Bestand wird gerechnet, nie gespeichert.** Ein gespeicherter Bestand
neben einer nachgetragenen Buchung wäre falsch, ohne falsch auszusehen — und
genau danach sucht ein Kassenprüfer. Bei den Größenordnungen einer
Feuerwehrkasse kostet das Rechnen nichts.
**Der Kategorievorschlag geht dem eigenen Bestand vor der Stichwortliste
nach.** Wenn eine Wehr Diesel seit Jahren unter „Fahrzeuge" bucht statt unter
„Kraftstoff", ist das ihre Entscheidung, und sie soll dabei bleiben dürfen.
Die Stichwortliste greift erst, wenn es noch keinen Bestand gibt. Unter der
Hälfte gemeinsamer Wörter schweigt der Vorschlag: ein falscher wird
übernommen, ohne gelesen zu werden.
**Der Bankimport erzeugt Entwürfe, keine Buchungen.** Und jeder Umsatz trägt
eine Kennung, damit ein zweimal eingelesener Auszug als solcher erkannt wird.
Ohne das wäre es ein doppelt gebuchter Monat, und der fiele erst beim
Kassenabschluss auf — also drei Monate später.
**Kein XML-Parser für CAMT.** Gebraucht werden sechs Felder aus einer flachen
Struktur, die Datei kommt von der eigenen Bank. Der Parser ist kürzer als die
Einbindung, und in einer Anwendung ohne Netzzugang ist jede Abhängigkeit
weniger eine Sorge weniger. Namensräume werden ignoriert — genau das, was ein
Parser hier auch täte.
**In der Prüfmaske stehen die gezählten Felder leer.** Der Buchbestand ist zu
sehen, weil der Prüfer ihn für die Differenz braucht. Vorbelegt wird nichts:
wer erst das Buch sieht und dann zählt, zählt, bis es passt.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Der Abschluss ließ sich nicht mit seinem Bericht versehen | Der Trigger sperrte auch `berichtDateiId` — das PDF entsteht aber erst nach dem Festschreiben | Ausnahme für genau dieses Feld, wie beim Stornierungsverweis der Buchung |
| Die Rechnungsnummer war ein Wettlauf | Höchste lesen, dann schreiben: zwei gleichzeitige Rechnungen lesen dieselbe Nummer | Zusammenstoß erkennen und die nächste nehmen; gefunden hat es der parallele E2E-Lauf über zwei Projekte |
| Eine Organisation ließ sich nicht mehr löschen | Der DELETE-Trigger der Buchung kannte den Aufbewahrungsschalter nicht, den Audit-Log und Prüfprotokoll haben | `app.audit_bereinigung` auch dort — ohne ihn wäre die DSGVO-Löschung nicht durchführbar |
| Der Datenbanktest ließ sich nur einmal ausführen | Dieselbe Ursache: das Aufräumen scheiterte an den festgeschriebenen Buchungen, und der Rest lief im nächsten Lauf gegen einen geprüften Zeitraum | siehe oben |
| E2E: „Barkasse" fand sieben Elemente | Das Wort steht auch in Verwendungszwecken | über die Kartenüberschrift angesprochen |
| E2E: die erste Maifest-Zeile war die falsche | Absteigend sortiert steht das laufende Jahr oben, und das ist nicht festgeschrieben | eine Buchung genommen, die es nur im Vorjahr gibt — und die Gegenprobe dazu |
| Prisma wollte zum zehnten Mal die Trigramm-Indizes aus Phase 1 löschen | Sie stehen in einer Roh-SQL-Migration, nicht im Schema | 30 DropIndex-Blöcke entfernt, Kommentar in die Migration |
| Der volle E2E-Lauf meldete quer durch alle Module Fehler | `npm run build` während eines laufenden `next dev`: der Produktionslauf überschreibt `.next`, und der Entwicklungsserver antwortet danach nur noch mit 500 | Entwicklungsserver beenden, `.next` löschen, neu laufen lassen — 239 grün |
| Ein E2E-Test aus Phase 0 wurde rot | Er sicherte zu, dass die Kasse **kein** Verweis ist — die Zusicherung ist mit dieser Phase abgelaufen | umgeschrieben: die Kasse ist jetzt ein Verweis. Ein künftiges Modul zum Ausgrauen gibt es nicht mehr |
### Abweichung von der Vorgabe
Keine. `Phase-9.md` ist vollständig umgesetzt, einschließlich CAMT.053, MT940,
Nummernkreis, Kassenprüfbericht und Export.
Der Beleg-Anhang an einer Buchung nutzt den `Belegentwurf` aus Phase 8 mit —
er hat dafür einen `buchungId` bekommen. Fotografieren und Erfassen war
bereits da; neu ist nur, dass daraus eine Buchung wird.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run lint` — ohne Befund
- `npm run test` — 562 Tests grün, davon 13 neu gegen eine echte Datenbank
- `npm run test:e2e` — 239 Tests grün über Desktop und Tablet, davon 26 neue
- `npm run build` — fünf neue Routen unter `/kasse`
- `npm run db:seed` — Kassenjahr angelegt, Abschluss festgeschrieben
- Rechnungs-PDF (41 kB) und Kassenprüfbericht (45 kB) einmal von Hand über
Gotenberg erzeugt und in MinIO abgelegt — die beiden Vorlagen haben sonst
keinen automatischen Nachweis
---