diff --git a/CHANGELOG.md b/CHANGELOG.md index 0efaf91..11321de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,168 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). -## [Unveröffentlicht] +## [0.10.0] — 2026-09-04 + +Phase 9: Kassenverwaltung. Kassenbuch, Mitgliedsbeiträge, Rechnungen an +Dritte, Kassenprüfung und die Auswertungen für die Jahreshauptversammlung. + +**Eine festgeschriebene Buchung ist unveränderbar.** Nicht, weil ein Zweig im +Router es so vorsieht, sondern weil die Datenbank es ablehnt. Korrigiert wird +über eine Gegenbuchung mit Verweis; beide Buchungen bleiben stehen, und im +Kassenbuch ist zu sehen, dass etwas berichtigt wurde. Genau das will ein +Kassenprüfer sehen — eine verschwundene Buchung dagegen kann er nicht prüfen. + +**Der Kassenabschluss schließt seinen Zeitraum.** Danach wird keine Buchung +mit einem Datum darin mehr angenommen, auch keine nachträglich hineindatierte. +Ohne diese zweite Hälfte stimmte der geprüfte Bestand nach einem +Datumswechsel nicht mehr — und niemand hätte es bemerkt. + +**Gerechnet wird mit `Prisma.Decimal`, nirgends mit Fließkomma.** `0.1 + 0.2` +ergibt in JavaScript 0.30000000000000004. In einer Kasse ist das kein +Rundungsfehler, sondern eine Differenz in der Prüfung, die niemand erklären +kann. + +### Hinzugefügt + +**Datenmodell** + +- `Kassenkonto` (bar, Bank, Rücklage) mit Anfangsbestand. Der laufende Bestand + wird **gerechnet und nie gespeichert**: ein gespeicherter Bestand neben + einer nachgetragenen Buchung wäre falsch, ohne falsch auszusehen. +- `Buchungskategorie` als zweistufiger Baum, mit Kontonummer für den Export an + die Kämmerei. +- `Buchung` mit Datum, Richtung, immer positivem Betrag, Verwendungszweck, + Belegnummer, Beleg-Datei und dem Verweis auf die Gegenbuchung. +- `Beitragssatz` und `Beitrag` (offen, bezahlt, erlassen, gemahnt) mit + Mahnstufe und Erlassbegründung. +- `Rechnung` und `Rechnungsposition` mit Nummernkreis je Jahr. Die Menge hat + drei Nachkommastellen — Einsatzstunden werden viertelstündlich abgerechnet. +- `Kassenabschluss` und `Abschlussposten` mit Bestand laut Buch, gezähltem + Bestand, Differenz, zwei Prüfern und dem Bericht als PDF. + +**Was die Datenbank durchsetzt** (Migration `20260904140000`) + +- RLS für alle neun Tabellen, wie in jeder Phase zuvor. +- Trigger `buchung_unveraenderlich` — festgeschriebene Buchungen lassen sich + weder ändern noch löschen. Änderbar bleibt genau eines: der Verweis auf die + Gegenbuchung. Eine Stornierung entsteht später; sie einzutragen ist keine + Änderung des Vorgangs, sondern seine Fortschreibung. +- Trigger `buchung_zeitraum_offen` — ein geprüfter Zeitraum nimmt weder eine + neue Buchung an noch das Hineindatieren einer vorhandenen. +- Trigger `abschluss_unveraenderlich` — ein festgeschriebener Abschluss bleibt, + wie er ist. Nachtragen lässt sich nur der Prüfbericht, denn das PDF entsteht + im Hintergrund und damit nach dem Festschreiben. +- `REVOKE DELETE ON kassenabschluss` für die Anwendung. +- CHECKs, die verhindern, was sich hinterher nicht mehr erkennen ließe: ein + Betrag von null oder darunter, eine Buchung an eine Person **und** einen + Dritten zugleich, ein erlassener Beitrag ohne Begründung, ein Abschluss mit + einer Differenz, die nicht zu seinen eigenen Zahlen passt, eine Rechnung, + die fällig wird, bevor sie geschrieben wurde. + +**Kassenbuch** + +- Buchungsmaske mit **Kategorievorschlag**: zuerst aus den eigenen früheren + Buchungen der Wehr, erst danach aus einer kurzen Stichwortliste. Unter der + Hälfte gemeinsamer Wörter schweigt der Vorschlag — ein falscher wird + übernommen, ohne gelesen zu werden. +- Summen über **alle** Treffer, nicht nur über die angezeigte Seite. +- Festschreiben als Sammelaktion bis zu einem Stichtag. +- Gegenbuchen mit Pflichtbegründung; das Buchungsdatum ist immer heute, damit + ein geprüfter Zeitraum zubleibt. +- **Aus einem gegengezeichneten Beleg wird eine Buchung.** Die Brücke zu + Phase 8: fotografiert und ausgefüllt wird am Terminal oder am Handy, + gebucht wird in der Kasse — mit Konto und Kategorie, die keine Maschine + entscheiden kann. Das Foto hängt danach an der Buchung, und genau das + verlangt eine Kassenprüfung: zu jeder Ausgabe ein Beleg, an der Buchung + und nicht in einem Ordner daneben. Ein Entwurf lässt sich nicht buchen. + +**Kontoauszug einlesen** + +- CAMT.053 und MT940, beides ohne zusätzliche Abhängigkeit. Erkannt wird am + Inhalt, nicht an der Dateiendung. +- Es entstehen **Entwürfe, keine fertigen Buchungen**. +- Jeder Umsatz trägt eine Kennung — die der Bank, sonst eine aus Datum, Betrag + und Zweck gebildete. Ein zweimal eingelesener Auszug wird dadurch als solcher + erkannt, statt einen Monat doppelt zu buchen. +- Was nicht gelesen werden konnte, steht mit Grund daneben, statt still zu + verschwinden. + +**Beiträge** + +- Sätze je Abteilung, gültig ab einem Jahr und nicht rückwirkend änderbar. +- Beitragslauf mit **Vorschau**: wie viele Beiträge entstehen, welche Summe — + und wer keinen passenden Satz hat. Der Lauf ist wiederholbar, ohne dass + etwas doppelt entsteht. +- Zahlungseingang erzeugt zugleich die Buchung. Ein Häkchen ohne Geld in der + Kasse wäre eine Behauptung. +- Mahnen als Sammelaktion, Erlassen nur mit Begründung. +- Rückstandsliste über alle Jahre — die Ansicht, in der auffällt, wer seit + drei Jahren offen steht. + +**Rechnungen** + +- Fortlaufende Nummer je Jahr, lückenlos: eine fehlende Nummer muss in der + Kassenprüfung erklärt werden. +- Der Betrag ist die Summe der Positionen, gerechnet und nicht eingegeben. +- Bis zum Versenden ein Entwurf, danach unveränderbar. Korrigiert wird über + Storno und eine neue Rechnung — die stornierte bleibt mit Grund stehen. +- PDF über Gotenberg, im Hintergrund erzeugt. + +**Kassenprüfung und Auswertung** + +- Prüfmaske: links der Bestand laut Buch, rechts der gezählte. In dieser + Reihenfolge — wer erst das Buch sieht und dann zählt, zählt, bis es passt. +- Ohne Prüfer und Prüfdatum lässt sich nichts festschreiben; eine Differenz + ohne Erläuterung ebenso wenig. +- Kassenprüfbericht als PDF mit vier Unterschriftsfeldern. +- Jahresauswertung nach Kategorie und Monat, mit Vorjahresvergleich. +- CSV-Export mit Semikolon und BOM — was Excel in Deutschland ohne Rückfrage + richtig öffnet. + +**Tests** + +- 67 neue Unit-Tests: Geldrechnen, beide Auszugsformate, Kategorievorschlag, + und dreizehn gegen eine echte Datenbank, die die Trigger und CHECKs prüfen. + Sie laufen über den Systemzugang: kommt nicht einmal der Eigentümer der + Datenbank daran vorbei, kommt niemand vorbei. +- 13 E2E-Tests über Desktop und Tablet. + +**Seed** + +- Ein vollständiges Kassenjahr: drei Konten, der Standardkontenrahmen, + 24 Buchungen über zwei Jahre, die Mitgliedsbeiträge mit Rückständen, zwei + Rechnungen und die festgeschriebene Kassenprüfung des Vorjahres. Die Zahlen + des Abschlusses sind die Summe der Buchungen und nicht erfunden. + +### Geändert + +- Ein E2E-Test aus Phase 0 sicherte zu, dass die Kasse in der Navigation + **kein** Verweis ist — sie war ja noch nicht da. Die Zusicherung ist mit + dieser Phase abgelaufen und prüft jetzt das Gegenteil. Ein künftiges Modul + zum Ausgrauen gibt es nicht mehr; kommt später eines dazu, gehört sie + zurück. + +### Behoben + +- **Die Rechnungsnummer war ein Wettlauf.** Höchste lesen und dann schreiben: + schreiben zwei Leute gleichzeitig eine Rechnung, lesen beide dieselbe + höchste Nummer, und die zweite läuft in den Eindeutigkeitsfehler. Gefunden + hat das der parallele E2E-Lauf. Der seltene Zusammenstoß wird jetzt erkannt + und die nächste Nummer genommen — lückenlos bleibt sie dabei. +- **Eine Organisation ließ sich nicht mehr löschen**, sobald sie eine + festgeschriebene Buchung hatte: der Trigger lehnte auch das Löschen im + Aufbewahrungslauf ab. Er kennt jetzt denselben Schalter wie das Audit-Log + und das Prüfprotokoll (`app.audit_bereinigung`). Ohne ihn wäre die + DSGVO-Löschung einer ausscheidenden Wehr nicht durchführbar gewesen. + +--- + +## [0.9.1] — 2026-09-04 Aufräumarbeiten vor Phase 9. Drei Punkte, die sich über mehrere Phasen angesammelt hatten und in jeder Phase Zeit gekostet haben. + ### Hinzugefügt - **ESLint** hat endlich eine Konfiguration (`eslint.config.mjs`, Flat Config). diff --git a/README.md b/README.md index afec67e..74fbd1e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env` | Phase 6 — Kartenmodule | fertig | | Phase 7 — Benachrichtigungen und Termine | fertig | | Phase 8 — Lokale KI | fertig | -| Phase 9 — Kassenverwaltung | offen | +| Phase 9 — Kassenverwaltung | fertig | Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md). @@ -1155,6 +1155,156 @@ Fehlermeldung der Datenbank durchzureichen, die niemand versteht. --- +## Phase 9 — Kassenverwaltung + +Kassenbuch, Mitgliedsbeiträge, Rechnungen an Dritte, Kassenprüfung und die +Zahlen für die Jahreshauptversammlung. + +Eine Feuerwehrkasse wird von einem Ehrenamtlichen geführt und von zwei anderen +geprüft. Was die Prüfung wert ist, hängt einzig daran, dass sich das Geprüfte +hinterher nicht mehr ändern lässt. Deshalb ist der zentrale Punkt dieser Phase +kein Formular, sondern eine Sperre. + +### Drei Festlegungen + +**Eine festgeschriebene Buchung ist unveränderbar.** Nicht, weil ein Zweig im +Router es so vorsieht, sondern weil die Datenbank es ablehnt — ein vergessener +Zustandscheck, ein Skript, ein Import würden sonst reichen. Korrigiert wird +über eine **Gegenbuchung** mit Verweis: beide Buchungen bleiben stehen, und im +Kassenbuch ist zu sehen, dass etwas berichtigt wurde. Genau das will ein +Kassenprüfer sehen; eine verschwundene Buchung kann er nicht prüfen. + +**Der Kassenabschluss schließt seinen Zeitraum.** Danach wird keine Buchung +mit einem Datum darin mehr angenommen — auch keine nachträglich +hineindatierte. Ohne diese zweite Hälfte stimmte der geprüfte Bestand nach +einem Datumswechsel nicht mehr, und niemand hätte es bemerkt. Wer nach der +Prüfung noch eine Quittung findet, bucht sie mit dem heutigen Datum. + +**Beträge sind `DECIMAL(12,2)`, gerechnet wird mit `Prisma.Decimal`.** +`0.1 + 0.2` ergibt in JavaScript 0.30000000000000004. In einer Kasse ist das +kein Rundungsfehler, sondern eine Differenz in der Prüfung, die niemand +erklären kann. + +### Das Kassenbuch + +Drei Kontenarten: Barkasse, Bankkonto, Rücklage. Der **Bestand wird gerechnet, +nie gespeichert** — Anfangsbestand plus die Wirkung aller Buchungen. Ein +gespeicherter Bestand neben einer nachgetragenen Buchung wäre falsch, ohne +falsch auszusehen, und genau danach sucht ein Kassenprüfer. + +Der Betrag einer Buchung ist immer positiv; die Richtung steht daneben. Eine +negative Ausgabe wäre eine Einnahme in Verkleidung und in keiner Auswertung +als solche zu erkennen — die Datenbank nimmt sie deshalb nicht an. + +Die Buchungsmaske schlägt eine Kategorie vor: zuerst aus den **eigenen +früheren Buchungen** der Wehr, erst danach aus einer kurzen Stichwortliste. +Der Nutzen liegt nicht in den gesparten Sekunden, sondern darin, dass dieselbe +Sache immer unter derselben Kategorie landet — erst das macht die +Jahresauswertung brauchbar. Unter der Hälfte gemeinsamer Wörter schweigt der +Vorschlag: ein falscher wird übernommen, ohne gelesen zu werden. + +Ein **gegengezeichneter Beleg** aus Phase 8 wird mit einem Klick zur Buchung: +Konto und Kategorie wählen, fertig. Betrag, Datum und Händler stehen schon +fest, und das Foto hängt danach an der Buchung — zu jeder Ausgabe ein Beleg, +an der Buchung und nicht in einem Ordner daneben. Ein Entwurf lässt sich +nicht buchen: er ist ein von einem Modell ausgefülltes Formular. + +### Kontoauszug einlesen + +Das Online-Banking bietet beides zum Herunterladen an, meist unter +„Umsatzexport“: + +| Format | Datei | Was gelesen wird | +|---|---|---| +| CAMT.053 | `.xml`, `.camt` | Datum, Richtung, Betrag, Verwendungszweck, Gegenseite, Bankkennung | +| MT940 | `.txt`, `.sta` | dasselbe, aus den Feldern `:61:` und `:86:` | + +Erkannt wird am Inhalt, nicht an der Dateiendung. Es entstehen **Entwürfe, +keine fertigen Buchungen** — durchsehen muss sie der Kassier. + +Jeder Umsatz trägt eine Kennung: die der Bank, sonst eine aus Datum, Betrag +und Zweck gebildete. Derselbe Auszug zweimal eingelesen wird dadurch als +solcher erkannt und in der Liste als „steht schon in der Kasse“ ausgewiesen. +Ohne das wäre es ein doppelt gebuchter Monat, und der fiele erst beim +Kassenabschluss auf. + +Was nicht gelesen werden konnte, steht mit Grund daneben. Ein Parser, der +stillschweigend drei Umsätze verschluckt, ist schlimmer als einer, der gar +nicht erst liest. + +### Mitgliedsbeiträge + +Sätze gelten ab einem Jahr und je Abteilung; ein Satz ohne Abteilung gilt für +alle übrigen. Rückwirkend geändert wird nichts — sonst stimmten die Beiträge +vergangener Jahre nicht mehr, und die sind bereits geprüft. + +Der **Beitragslauf** zeigt vorher, was entstehen wird: wie viele Beiträge, +welche Summe, und wer keinen passenden Satz hat. Der letzte Teil ist der +interessante — das sind die Leute, für die niemand entschieden hat, was sie +zahlen. Der Lauf ist wiederholbar; im Februar noch einmal gestartet legt er +nur die Neuen an. + +Ein Zahlungseingang erzeugt zugleich die Buchung. Ein Häkchen ohne Geld in der +Kasse wäre eine Behauptung. Ein Erlass braucht eine Begründung — die Datenbank +lehnt einen ohne ab, weil er in der Kassenprüfung sonst eine offene Frage ist. + +Die Rückstandsliste geht über alle Jahre. Sie ist die einzige Ansicht, in der +auffällt, wer seit drei Jahren offen steht. + +### Rechnungen an Dritte + +Kostenersatz nach Art. 28 BayFwG, Vermietungen, Veranstaltungen. Die Nummer +vergibt FLORIAN fortlaufend je Jahr und **lückenlos**: eine fehlende Nummer +muss in der Kassenprüfung erklärt werden. + +Der Betrag ist die Summe der Positionen, gerechnet und nicht eingegeben. Ein +eingetippter Gesamtbetrag neben Positionen, die etwas anderes ergeben, ist der +Fehler, den niemand bemerkt, bis der Empfänger anruft. Die Menge hat drei +Nachkommastellen — Einsatzstunden werden viertelstündlich abgerechnet, und +0,25 × 47,50 muss auf den Cent stimmen. + +Bis zum Versenden ist eine Rechnung ein Entwurf, danach unveränderbar: was +beim Empfänger liegt, muss dem entsprechen, was hier steht. Korrigiert wird +über Storno und eine neue Rechnung; die stornierte bleibt mit Grund stehen. + +### Kassenprüfung + +In der Prüfmaske steht links der Bestand laut Buch und rechts der gezählte. +In dieser Reihenfolge, und die gezählten Felder fangen leer an — wer erst das +Buch sieht und dann zählt, zählt, bis es passt. + +Ohne Prüfer und Prüfdatum lässt sich nichts festschreiben: ohne beides ist es +kein Abschluss, sondern eine Notiz. Eine Differenz braucht eine Erläuterung, +sonst steht in fünf Jahren „stimmt nicht“ im Bericht und niemand weiß mehr, +warum. + +Der Kassenprüfbericht entsteht als PDF mit vier Unterschriftsfeldern und wird +im Hintergrund erzeugt. + +### Auswertungen + +Einnahmen und Ausgaben nach Kategorie und Monat, mit dem Vorjahr daneben — +eine Zahl ohne Vergleich sagt nichts. Der Export ist eine CSV-Datei mit +Semikolon und BOM: das ist, was Excel in Deutschland ohne Rückfrage richtig +öffnet. Eine Datei, die beim Doppelklick als eine Spalte voller Semikolons +erscheint, wird zurückgeschickt. + +### Wer was sieht + +| Recht | Wer | Was | +|---|---|---| +| `kasse.lesen` | Kassier, Kommandant, Vorstand | Buchungen und Auswertungen ansehen | +| `kasse.bearbeiten` | Kassier | Buchungen, Beiträge, Rechnungen erfassen | +| `kasse.abschluss` | Kassier, Kassenprüfer | Kassenabschluss anlegen und festschreiben | + +Der Gerätewart sieht keine Buchungen, der Kassier keine Atemschutzdaten. Die +Trennung liegt in `lib/authz.ts`, nicht in den Komponenten. + +**Keine zusätzlichen Umgebungsvariablen.** Die Kasse braucht nur, was ohnehin +läuft: Postgres, MinIO für die Belege und Gotenberg für die PDFs. + +--- + ## Befehle ```bash diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index ceababb..922c045 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -1,10 +1,8 @@ # Anleitung -Bedienung von FLORIAN, Stand Phase 8 (Version 0.9.0). Die Bilder zeigen die +Bedienung von FLORIAN, Stand Phase 9 (Version 0.10.0). Die Bilder zeigen die Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt. -> Die Kassenverwaltung kommt in Phase 9 dazu. Siehe [ROADMAP.md](ROADMAP.md). - --- ## 1. Anmelden @@ -1680,9 +1678,9 @@ Prüfen Sie gegen das Foto, das links daneben steht. Dann **Gegenzeichnen**. Ohne Vision-Modell funktioniert alles bis auf das Auslesen: der Beleg wird fotografiert und abgelegt, die Angaben tragen Sie von Hand ein. -Die Buchung daraus kommt mit der Kasse in Phase 9. Hier entsteht erst einmal ein -sauber erfasster Beleg mit Foto — und der ist schon mehr wert als ein -Schuhkarton. +Ist der Beleg gegengezeichnet, steht in der Liste der erledigten Belege ein +Knopf **Buchen**. Er führt in einem Schritt ins Kassenbuch — zu wählen sind +nur Konto und Kategorie. Siehe [Kapitel 48](#48-buchen). ### Das KI-Protokoll @@ -1704,7 +1702,291 @@ Datensatz, seit dem ersten Tag. --- -## 47. Rollen und was sie dürfen +## 47. Die Kasse + +Unter **Kasse** stehen alle Konten der Wehr mit ihrem heutigen Bestand. + +![Kassenübersicht](screenshots/45-kasse-light.png) + +Drei Arten von Konten: + +| Art | Wofür | +|---|---| +| **Barkasse** | Die Kassette im Gerätehaus. Wechselgeld für Feste, Kleinausgaben. | +| **Bankkonto** | Das Girokonto der Wehr. Mit IBAN, damit sich Kontoauszüge zuordnen lassen. | +| **Rücklage** | Angespartes für eine Beschaffung. Wird nicht laufend bewegt. | + +**Der Bestand wird gerechnet, nicht gespeichert.** Anfangsbestand plus alle +Buchungen. Wer nachträglich eine Buchung einträgt, sieht den Bestand sofort +richtig — es gibt keinen zweiten Wert, der danebenläuft. + +### Beim ersten Mal + +Steht die Kasse leer, bietet die Übersicht an, den **Standardkontenrahmen** +anzulegen: rund zwanzig Kategorien, wie sie eine Feuerwehrkasse braucht — +Kraftstoff, Reparatur, Schutzkleidung, Ausbildung, Verpflegung auf der einen +Seite; Zuschüsse, Spenden, Mitgliedsbeiträge, Veranstaltungen, Kostenersatz +auf der anderen. + +Sie lassen sich danach umbenennen, ergänzen und um eigene Unterkategorien +erweitern. Wer eine eigene Gliederung hat, legt sie stattdessen von Hand an. + +--- + +## 48. Buchen + +![Kassenbuch](screenshots/46-kassenbuch-light.png) + +Oben stehen drei Summen: Einnahmen, Ausgaben, Saldo. Sie beziehen sich auf +**alle** Treffer der eingestellten Auswahl, nicht nur auf die angezeigten +Zeilen — eine Summe unter einer abgeschnittenen Liste bedeutete nichts. + +### Eine Buchung erfassen + +Der Betrag ist immer positiv; ob es eine Einnahme oder eine Ausgabe ist, steht +mit den beiden Knöpfen daneben. Eine „negative Ausgabe" gibt es nicht: sie +wäre eine Einnahme in Verkleidung und in keiner Auswertung als solche zu +erkennen. + +Sobald ein Verwendungszweck dasteht, schlägt FLORIAN eine Kategorie vor. +Zuerst aus den **eigenen früheren Buchungen** der Wehr — steht darunter „zuletzt +so gebucht", ist dieselbe Sache schon einmal so einsortiert worden. Erst wenn +es dazu nichts gibt, greift eine Stichwortliste. + +Der Vorschlag ist ein Angebot, kein Automatismus: er wird erst übernommen, +wenn er angeklickt wird. Und ist er unsicher, erscheint gar keiner. Ein +falscher Vorschlag wäre schlechter als keiner — er wird übernommen, ohne +gelesen zu werden. + +### Entwurf und festgeschrieben + +Eine neue Buchung ist zunächst ein **Entwurf**. Sie lässt sich ändern und +verwerfen, solange der Kassier sie durchsieht. + +Mit **Festschreiben** wird sie unveränderbar. Das ist der Zustand, den eine +Kassenprüfung erwartet, und dahinter führt kein Weg zurück — auch nicht über +einen Umweg, die Datenbank lehnt jede Änderung ab. + +Ein Schloss in der Zeile zeigt, welche Buchungen festgeschrieben sind. + +### Einen Beleg buchen + +Wurde eine Quittung unter **Belege** fotografiert und gegengezeichnet, muss +sie nicht noch einmal abgetippt werden. In der Liste der erledigten Belege +steht dort ein Knopf **Buchen**. + +Zu wählen sind nur Konto und Kategorie — alles andere steht schon fest. +Betrag, Datum und Händler kommen aus dem gegengezeichneten Beleg, und das +Foto hängt danach an der Buchung. Das ist der Zustand, den eine +Kassenprüfung erwartet: zu jeder Ausgabe ein Beleg, und zwar an der Buchung +und nicht in einem Ordner daneben. + +Ein Beleg, der noch nicht gegengezeichnet ist, lässt sich nicht buchen. Er +ist bis dahin ein von einer Maschine ausgefülltes Formular. + +### Wenn etwas falsch war + +Bei einem Entwurf: ändern oder verwerfen. + +Bei einer festgeschriebenen Buchung: **Gegenbuchen**. Es entsteht eine zweite +Buchung mit derselben Summe in die andere Richtung, mit Verweis auf die erste +und mit dem eingegebenen Grund im Verwendungszweck. Beide bleiben stehen. + +Das sieht nach mehr Aufwand aus, als eine Zahl zu korrigieren, und ist genau +der Punkt: eine verschwundene Buchung kann ein Kassenprüfer nicht prüfen. Eine +berichtigte kann er nachvollziehen. + +Das Datum der Gegenbuchung ist immer **heute**. Läge es im geprüften Zeitraum, +würde die Datenbank sie ablehnen — und das zu Recht. + +--- + +## 49. Kontoauszug einlesen + +Statt fünfzig Umsätze abzutippen, lässt sich der Auszug aus dem Online-Banking +einlesen. Zwei Formate: + +| Format | Datei | +|---|---| +| **CAMT.053** | `.xml` oder `.camt` | +| **MT940** | `.txt` oder `.sta` | + +Beides bietet fast jedes Online-Banking zum Herunterladen an, meist unter +„Umsatzexport" oder „Umsätze exportieren". Welches von beiden, spielt keine +Rolle — FLORIAN erkennt es am Inhalt. + +### Was dabei passiert — und was nicht + +Es entstehen **Entwürfe**, keine fertigen Buchungen. Jeder Posten muss danach +noch durchgesehen und die Kategorie geprüft werden. + +Umsätze, die schon in der Kasse stehen, sind grau hinterlegt und mit „steht +schon in der Kasse" gekennzeichnet. Sie sind nicht vorausgewählt. Das ist der +eigentliche Wert der Sache: ein zweimal eingelesener Auszug wäre sonst ein +doppelt gebuchter Monat, und der fällt erst beim Kassenabschluss auf — also +drei Monate später. + +Umsätze, die nicht gelesen werden konnten, stehen mit Grund darüber. Sie +verschwinden nicht stillschweigend. + +--- + +## 50. Mitgliedsbeiträge + +![Beiträge](screenshots/47-beitraege-light.png) + +### Sätze + +Ein Beitragssatz gilt **ab einem Jahr** und wahlweise für eine Abteilung. Ein +Satz ohne Abteilung gilt für alle, die keinen eigenen haben. + +Rückwirkend wird nichts geändert. Wer den Beitrag erhöht, legt einen neuen +Satz ab dem kommenden Jahr an — sonst stimmten die Beiträge der vergangenen +Jahre nicht mehr, und die sind bereits geprüft. + +### Der Beitragslauf + +Einmal im Jahr, meist im Januar. Vorher zeigt FLORIAN, was entstehen wird: + +- wie viele Beiträge und über welche Summe +- wie viele Mitglieder für dieses Jahr schon einen haben (die bleiben unberührt) +- **wer keinen passenden Satz hat** + +Der letzte Punkt ist der wichtige. Das sind die Mitglieder, für die niemand +entschieden hat, was sie zahlen — entweder fehlt ein Satz für ihre Abteilung +oder es gibt keinen allgemeinen. + +Der Lauf lässt sich wiederholen. Wer im Februar drei Neue aufgenommen hat, +startet ihn noch einmal; doppelt entsteht nichts. + +### Zahlungen und Mahnungen + +Ein Zahlungseingang erzeugt zugleich die **Buchung** im Kassenbuch. Ein +Häkchen ohne Geld in der Kasse wäre eine Behauptung. + +Gemahnt wird als Sammelaktion: die offenen Beiträge auswählen, mahnen. Die +Mahnstufe zählt hoch. Ab der fünften Stufe hört FLORIAN auf — was danach +kommt, entscheidet die Kommandantschaft und nicht die Software. + +Ein **Erlass** braucht eine Begründung. Ohne sie wird er nicht gespeichert: +ein erlassener Beitrag ohne Grund ist in der Kassenprüfung eine offene Frage, +und in fünf Jahren weiß es niemand mehr. + +### Rückstände + +Ganz oben steht die Rückstandsliste über **alle Jahre**, nach Person +gruppiert. Sie ist die einzige Ansicht, in der auffällt, wer seit drei Jahren +offen steht — in der Jahresliste sieht das aus wie ein einzelner offener +Beitrag. + +--- + +## 51. Rechnungen an Dritte + +![Rechnungen](screenshots/48-rechnungen-light.png) + +Kostenersatz nach Art. 28 BayFwG (Fehlalarm einer Brandmeldeanlage, Öleinsatz +mit Verursacher), Vermietung des Schulungsraums, Absicherung einer +Veranstaltung. + +### Nummer und Betrag + +Die **Rechnungsnummer** vergibt FLORIAN fortlaufend je Jahr, lückenlos. Eine +fehlende Nummer muss in der Kassenprüfung erklärt werden, deshalb wird auch +eine stornierte Rechnung nicht gelöscht, sondern bleibt mit Grund stehen. + +Der **Betrag** ist die Summe der Positionen und wird nicht eingegeben. Ein von +Hand eingetippter Gesamtbetrag neben Positionen, die etwas anderes ergeben, +ist der Fehler, den niemand bemerkt, bis der Empfänger anruft. + +Die Menge hat drei Nachkommastellen: Einsatzstunden werden vielerorts +viertelstündlich abgerechnet, und 0,25 × 47,50 € muss auf den Cent stimmen. + +### Entwurf, versendet, bezahlt + +Bis zum **Versenden** ist eine Rechnung ein Entwurf und lässt sich vollständig +ändern. Danach nicht mehr: was beim Empfänger liegt, muss dem entsprechen, was +hier steht. + +Beim Versenden entsteht das PDF im Hintergrund. Es steht kurz darauf zum +Herunterladen bereit. + +Der Zahlungseingang erzeugt — wie beim Beitrag — zugleich die Buchung. Wurde +weniger überwiesen als berechnet, lässt sich der abweichende Betrag eintragen. + +Für eine Korrektur: stornieren und neu schreiben. Eine bezahlte Rechnung wird +nicht storniert; dort ist zuerst die Buchung gegenzubuchen. + +--- + +## 52. Kassenprüfung + +![Kassenabschluss](screenshots/49-kassenabschluss-light.png) + +Einmal im Jahr, meist vor der Jahreshauptversammlung. Zwei Kassenprüfer zählen +die Kasse und vergleichen mit dem Buch. + +### Der Ablauf + +1. **Zeitraum wählen.** In der Regel das abgelaufene Kalenderjahr. +2. **Zählen.** In der Maske stehen links die Bestände laut Buch und rechts + leere Felder für das Gezählte. Die Reihenfolge ist Absicht: wer erst das + Buch sieht und dann zählt, zählt, bis es passt. +3. **Differenz erklären**, falls eine bleibt. +4. **Prüfer und Prüfdatum eintragen.** +5. **Festschreiben.** + +Steht ein Hinweis auf noch nicht festgeschriebene Buchungen im Zeitraum: erst +durchsehen. Das Festschreiben des Abschlusses nimmt sie mit. + +### Was das Festschreiben bewirkt + +Der Zeitraum ist danach **zu**. Es lässt sich keine Buchung mehr mit einem +Datum darin anlegen, und keine vorhandene mehr dorthin verschieben. Wer nach +der Prüfung noch eine Quittung findet, bucht sie mit dem heutigen Datum. + +Der Abschluss selbst ist ebenfalls unveränderbar — Zahlen, Prüfer und +Erläuterung stehen fest. Nachgetragen wird nur der Prüfbericht, weil das PDF +im Hintergrund entsteht. + +Ohne Prüfer und Prüfdatum lässt sich nichts festschreiben: ohne beides ist es +kein Abschluss, sondern eine Notiz. Und eine Differenz ohne Erläuterung +ebenfalls nicht — sonst steht in fünf Jahren „stimmt nicht" im Bericht und +niemand weiß mehr, warum. + +### Der Prüfbericht + +Entsteht als PDF mit vier Unterschriftsfeldern: beide Prüfer, der Kassier und +der Kommandant. Er lässt sich ausdrucken, unterschreiben und abheften — die +Papierform bleibt für die Entlastung in der Versammlung die maßgebliche. + +--- + +## 53. Auswertungen und Export + +Unter **Kasse → Abschluss → Auswertung** stehen die Zahlen für die +Jahreshauptversammlung: + +- Einnahmen und Ausgaben je Kategorie, absteigend nach Betrag +- der Verlauf über zwölf Monate +- das Vorjahr daneben — eine Zahl ohne Vergleich sagt nichts + +Das ist die Ansicht, in der auffällt, dass sich die Kraftstoffkosten verdoppelt +haben. + +### Export + +**Kassenbuch als CSV** lädt alle Buchungen eines Jahres herunter: Datum, +Konto, Richtung, Betrag, Ober- und Unterkategorie, Kontonummer, +Verwendungszweck, Belegnummer, Gegenüber und ob festgeschrieben. + +Die Datei ist für den Steuerberater oder die Kämmerei gedacht und öffnet in +Excel ohne Rückfrage richtig: Semikolon als Trennzeichen, Komma als +Dezimaltrennzeichen, UTF-8 mit Kennung. + +--- + +## 54. Rollen und was sie dürfen Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in `src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr @@ -1716,7 +1998,7 @@ kann sie also nachträglich zuschneiden, ohne dass jemand Code ändert. | **Kommandant** | alles innerhalb der eigenen Wehr, einschließlich Rollen vergeben und Protokoll einsehen | | **Gerätewart** | Fahrzeuge und Geräte anlegen, ändern, aussondern; Prüfungen planen und durchführen | | **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 | -| **Kassier** | Buchungen und Kassenabschluss (Modul in Phase 9), Belege erfassen und gegenzeichnen | +| **Kassier** | Kassenbuch, Beiträge, Rechnungen und Kassenabschluss; Belege erfassen und gegenzeichnen | | **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen | | **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen | | **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten, im Wiki lesen | @@ -1738,7 +2020,7 @@ sind nur die Textvorlagen, und die hängen an einem eigenen Recht. --- -## 48. Anmeldung über authentik einrichten +## 55. Anmeldung über authentik einrichten authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks. @@ -1791,7 +2073,7 @@ ganze Wehr aus. --- -## 49. PIN und Ausweiskarte verwalten +## 56. PIN und Ausweiskarte verwalten Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum, was dahinter liegt. @@ -1814,7 +2096,7 @@ Huber), PIN `471203`. --- -## 50. Was protokolliert wird +## 57. Was protokolliert wird Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person, betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. @@ -1839,7 +2121,7 @@ jeweiligen Detailseite sichtbar. --- -## 51. Wenn etwas nicht geht +## 58. Wenn etwas nicht geht | Beobachtung | Wahrscheinliche Ursache | Was hilft | |---|---|---| diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md index f20ac5c..2a15658 100644 --- a/docs/AUFWANDSSCHAETZUNG.md +++ b/docs/AUFWANDSSCHAETZUNG.md @@ -39,8 +39,8 @@ Faktoren, die in die Schätzung eingehen: | 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** | | 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** | | 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** | -| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen | -| | **Summe** | **123–164 PT** | **28–37 h** | | +| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 h** | +| | **Summe** | **123–164 PT** | **28–37 h** | **28:30 h** | 123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet @@ -422,6 +422,16 @@ Die Unveränderlichkeit festgeschriebener Buchungen kostet hier fast nichts — das Muster steht seit Phase 0 mit dem Audit-Log-Trigger und lässt sich übernehmen. +**Tatsächlich: 4:10 h** — die längste Phase bisher. Beide Schätzungen oben +haben sich bestätigt und zugleich verschoben: MT940 kostete weniger als +gedacht, weil beide Formate ohne Fremdbibliothek auskommen. Dafür war die +Unveränderlichkeit nicht das Übernehmen eines Musters, sondern drei +Trigger mit drei genau begründeten Ausnahmen — und die Ausnahmen sind der +Teil, der Zeit gekostet hat. Zwei davon sind erst im Test aufgefallen: +der Prüfbericht, der nach dem Festschreiben entsteht, und die +DSGVO-Löschung einer Wehr, die an ihren eigenen festgeschriebenen Buchungen +scheiterte. + --- ## Was hier nicht drin ist diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 609fcdb..562e87c 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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 --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0e3916b..8232d6c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -18,7 +18,7 @@ Phase 5 ████████████████████ fertig Phase 6 ████████████████████ fertig 0.7.0 Phase 7 ████████████████████ fertig 0.8.0 Phase 8 ████████████████████ fertig 0.9.0 -Phase 9 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.10.0 +Phase 9 ████████████████████ fertig 0.10.0 Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0 ``` @@ -234,7 +234,7 @@ Dafür bleibt **alles andere ohne Modell vollständig benutzbar**: Wiki lesen un schreiben, Wortsuche, Belege ablegen, beide Protokolle. Wo etwas fehlt, steht ein Satz, der sagt was fehlt und wie es ohne geht. -## Phase 9 — Kassenverwaltung · als Nächstes +## Phase 9 — Kassenverwaltung · fertig Am 03.09.2026 ergänzt. Die Rolle `KASSIER` war seit Phase 0 angelegt, ein Modul dazu fehlte in der ursprünglichen Planung. Spezifikation: `Phase-9.md`. @@ -253,7 +253,11 @@ Kassenabschluss friert seinen Zeitraum ein, danach nimmt die Buchungsmaske kein Datum daraus mehr an. Beträge durchgängig als `DECIMAL(12,2)`, nie als Fließkommazahl. -**Danach kann die Wehr:** ihre Kasse führen und den Kassenprüfbericht auf +Beides sitzt in Triggern und nicht im Router: ein vergessener Zustandscheck, +ein Skript oder ein Import würden sonst reichen, um den geprüften Bestand zu +ändern, ohne dass irgendwo etwas auffällt. + +**Die Wehr kann jetzt:** ihre Kasse führen und den Kassenprüfbericht auf Knopfdruck erzeugen. ## 1.0.0 — Freigabe @@ -276,7 +280,7 @@ Am 03.09.2026 entschieden: | Punkt | Entscheidung | |---|---| -| Kassenmodul | Wird als **Phase 9** ergänzt. Spezifikation in `Phase-9.md`, Aufwand 8–11 PT. | +| Kassenmodul | Als **Phase 9** ergänzt und mit 0.10.0 umgesetzt. Spezifikation in `Phase-9.md`. | | `Berechtigung` ohne `organisationId` | Bleibt so. Die Tabelle ist der systemweite Katalog dessen, was die Software kann, für alle Wehren identisch — mandantenbezogen wird es erst über `RolleBerechtigung`. Für die Anwendungsrolle nur lesbar. | | Löschausnahme im Audit-Log | Bleibt so. `UPDATE` ist dauerhaft gesperrt, `DELETE` nur mit `SET LOCAL app.audit_bereinigung = 'an'` im Aufbewahrungslauf des Workers. Ohne diese Ausnahme wäre das Löschkonzept nach DSGVO nicht umsetzbar. | diff --git a/docs/screenshots/02-uebersicht-dark.png b/docs/screenshots/02-uebersicht-dark.png index cfb178d..0766e4c 100644 Binary files a/docs/screenshots/02-uebersicht-dark.png and b/docs/screenshots/02-uebersicht-dark.png differ diff --git a/docs/screenshots/02-uebersicht-light.png b/docs/screenshots/02-uebersicht-light.png index 6439d45..f5bc0e2 100644 Binary files a/docs/screenshots/02-uebersicht-light.png and b/docs/screenshots/02-uebersicht-light.png differ diff --git a/docs/screenshots/22-einsatz-dark.png b/docs/screenshots/22-einsatz-dark.png index d9e7570..d31a9b6 100644 Binary files a/docs/screenshots/22-einsatz-dark.png and b/docs/screenshots/22-einsatz-dark.png differ diff --git a/docs/screenshots/22-einsatz-light.png b/docs/screenshots/22-einsatz-light.png index f46144c..b4d2bb4 100644 Binary files a/docs/screenshots/22-einsatz-light.png and b/docs/screenshots/22-einsatz-light.png differ diff --git a/docs/screenshots/24-portal-dark.png b/docs/screenshots/24-portal-dark.png index d433f94..6edfe2f 100644 Binary files a/docs/screenshots/24-portal-dark.png and b/docs/screenshots/24-portal-dark.png differ diff --git a/docs/screenshots/24-portal-light.png b/docs/screenshots/24-portal-light.png index 78ca48b..ee663bf 100644 Binary files a/docs/screenshots/24-portal-light.png and b/docs/screenshots/24-portal-light.png differ diff --git a/docs/screenshots/26-kiosk-mangel-dark.png b/docs/screenshots/26-kiosk-mangel-dark.png index 3c97cf4..ded6df7 100644 Binary files a/docs/screenshots/26-kiosk-mangel-dark.png and b/docs/screenshots/26-kiosk-mangel-dark.png differ diff --git a/docs/screenshots/27-kiosk-monitor-dark.png b/docs/screenshots/27-kiosk-monitor-dark.png index e19b90c..eb1e3d7 100644 Binary files a/docs/screenshots/27-kiosk-monitor-dark.png and b/docs/screenshots/27-kiosk-monitor-dark.png differ diff --git a/docs/screenshots/27-kiosk-monitor-light.png b/docs/screenshots/27-kiosk-monitor-light.png index ac2063f..cbfc7d4 100644 Binary files a/docs/screenshots/27-kiosk-monitor-light.png and b/docs/screenshots/27-kiosk-monitor-light.png differ diff --git a/docs/screenshots/28-terminal-uebersicht-light.png b/docs/screenshots/28-terminal-uebersicht-light.png index 8b3c742..4260534 100644 Binary files a/docs/screenshots/28-terminal-uebersicht-light.png and b/docs/screenshots/28-terminal-uebersicht-light.png differ diff --git a/docs/screenshots/29-anmeldemarke-dark.png b/docs/screenshots/29-anmeldemarke-dark.png index 11705c8..ee0a585 100644 Binary files a/docs/screenshots/29-anmeldemarke-dark.png and b/docs/screenshots/29-anmeldemarke-dark.png differ diff --git a/docs/screenshots/29-anmeldemarke-light.png b/docs/screenshots/29-anmeldemarke-light.png index fa0695b..ddec0b1 100644 Binary files a/docs/screenshots/29-anmeldemarke-light.png and b/docs/screenshots/29-anmeldemarke-light.png differ diff --git a/docs/screenshots/31-atemschutz-dark.png b/docs/screenshots/31-atemschutz-dark.png index b59c5a0..ed66f2d 100644 Binary files a/docs/screenshots/31-atemschutz-dark.png and b/docs/screenshots/31-atemschutz-dark.png differ diff --git a/docs/screenshots/32-atemschutz-nachweise-dark.png b/docs/screenshots/32-atemschutz-nachweise-dark.png index 9071971..ebed6f1 100644 Binary files a/docs/screenshots/32-atemschutz-nachweise-dark.png and b/docs/screenshots/32-atemschutz-nachweise-dark.png differ diff --git a/docs/screenshots/33-atemschutz-ueberwachung-dark.png b/docs/screenshots/33-atemschutz-ueberwachung-dark.png index a142635..70df9be 100644 Binary files a/docs/screenshots/33-atemschutz-ueberwachung-dark.png and b/docs/screenshots/33-atemschutz-ueberwachung-dark.png differ diff --git a/docs/screenshots/34-karte-dark.png b/docs/screenshots/34-karte-dark.png index 746d4bd..9ef7277 100644 Binary files a/docs/screenshots/34-karte-dark.png and b/docs/screenshots/34-karte-dark.png differ diff --git a/docs/screenshots/35-objekte-dark.png b/docs/screenshots/35-objekte-dark.png index f6d5f4a..d017023 100644 Binary files a/docs/screenshots/35-objekte-dark.png and b/docs/screenshots/35-objekte-dark.png differ diff --git a/docs/screenshots/38-benachrichtigungen-dark.png b/docs/screenshots/38-benachrichtigungen-dark.png index d72218a..c00bb6e 100644 Binary files a/docs/screenshots/38-benachrichtigungen-dark.png and b/docs/screenshots/38-benachrichtigungen-dark.png differ diff --git a/docs/screenshots/39-vorlagen-dark.png b/docs/screenshots/39-vorlagen-dark.png index 2a49bfb..74bb2aa 100644 Binary files a/docs/screenshots/39-vorlagen-dark.png and b/docs/screenshots/39-vorlagen-dark.png differ diff --git a/docs/screenshots/40-dienstausweis-dark.png b/docs/screenshots/40-dienstausweis-dark.png index c42a9de..802c643 100644 Binary files a/docs/screenshots/40-dienstausweis-dark.png and b/docs/screenshots/40-dienstausweis-dark.png differ diff --git a/docs/screenshots/40-dienstausweis-light.png b/docs/screenshots/40-dienstausweis-light.png index c596d47..c66d263 100644 Binary files a/docs/screenshots/40-dienstausweis-light.png and b/docs/screenshots/40-dienstausweis-light.png differ diff --git a/docs/screenshots/42-belege-dark.png b/docs/screenshots/42-belege-dark.png index 837b030..06679af 100644 Binary files a/docs/screenshots/42-belege-dark.png and b/docs/screenshots/42-belege-dark.png differ diff --git a/docs/screenshots/42-belege-light.png b/docs/screenshots/42-belege-light.png index 279665d..b64335d 100644 Binary files a/docs/screenshots/42-belege-light.png and b/docs/screenshots/42-belege-light.png differ diff --git a/docs/screenshots/45-kasse-dark.png b/docs/screenshots/45-kasse-dark.png new file mode 100644 index 0000000..01f29f6 Binary files /dev/null and b/docs/screenshots/45-kasse-dark.png differ diff --git a/docs/screenshots/45-kasse-light.png b/docs/screenshots/45-kasse-light.png new file mode 100644 index 0000000..d1d6f6b Binary files /dev/null and b/docs/screenshots/45-kasse-light.png differ diff --git a/docs/screenshots/46-kassenbuch-dark.png b/docs/screenshots/46-kassenbuch-dark.png new file mode 100644 index 0000000..0bb5e3c Binary files /dev/null and b/docs/screenshots/46-kassenbuch-dark.png differ diff --git a/docs/screenshots/46-kassenbuch-light.png b/docs/screenshots/46-kassenbuch-light.png new file mode 100644 index 0000000..57fadfb Binary files /dev/null and b/docs/screenshots/46-kassenbuch-light.png differ diff --git a/docs/screenshots/47-beitraege-dark.png b/docs/screenshots/47-beitraege-dark.png new file mode 100644 index 0000000..badbc7d Binary files /dev/null and b/docs/screenshots/47-beitraege-dark.png differ diff --git a/docs/screenshots/47-beitraege-light.png b/docs/screenshots/47-beitraege-light.png new file mode 100644 index 0000000..2646cdf Binary files /dev/null and b/docs/screenshots/47-beitraege-light.png differ diff --git a/docs/screenshots/48-rechnungen-dark.png b/docs/screenshots/48-rechnungen-dark.png new file mode 100644 index 0000000..3eca604 Binary files /dev/null and b/docs/screenshots/48-rechnungen-dark.png differ diff --git a/docs/screenshots/48-rechnungen-light.png b/docs/screenshots/48-rechnungen-light.png new file mode 100644 index 0000000..50a7d46 Binary files /dev/null and b/docs/screenshots/48-rechnungen-light.png differ diff --git a/docs/screenshots/49-kassenabschluss-dark.png b/docs/screenshots/49-kassenabschluss-dark.png new file mode 100644 index 0000000..5e37db2 Binary files /dev/null and b/docs/screenshots/49-kassenabschluss-dark.png differ diff --git a/docs/screenshots/49-kassenabschluss-light.png b/docs/screenshots/49-kassenabschluss-light.png new file mode 100644 index 0000000..9a8688f Binary files /dev/null and b/docs/screenshots/49-kassenabschluss-light.png differ diff --git a/package.json b/package.json index 248014b..f05121d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "florian", - "version": "0.9.0", + "version": "0.10.0", "private": true, "description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "engines": { diff --git a/prisma/migrations/20260904134729_kassenverwaltung/migration.sql b/prisma/migrations/20260904134729_kassenverwaltung/migration.sql new file mode 100644 index 0000000..81016c4 --- /dev/null +++ b/prisma/migrations/20260904134729_kassenverwaltung/migration.sql @@ -0,0 +1,346 @@ +-- ============================================================================= +-- Phase 9 — Kassenverwaltung +-- +-- HINWEIS zu den entfernten DropIndex-Bloecken: Prisma schlaegt bei jeder +-- Migration erneut vor, die von Hand angelegten GiST- und Trigram-Indizes aus +-- Phase 1 abzuraeumen. Es kennt sie nicht, weil sie nicht aus dem Schema +-- stammen. Neunte Migration in Folge, in der sie wieder herausgenommen werden. +-- ============================================================================= + +-- CreateEnum +CREATE TYPE "KontoArt" AS ENUM ('BAR', 'BANK', 'RUECKLAGE'); + +-- CreateEnum +CREATE TYPE "BuchungsArt" AS ENUM ('EINNAHME', 'AUSGABE'); + +-- CreateEnum +CREATE TYPE "BeitragStand" AS ENUM ('OFFEN', 'BEZAHLT', 'ERLASSEN', 'GEMAHNT'); + +-- CreateEnum +CREATE TYPE "RechnungStand" AS ENUM ('ENTWURF', 'VERSENDET', 'BEZAHLT', 'GEMAHNT', 'STORNIERT'); + +-- CreateEnum +CREATE TYPE "RechnungsAnlass" AS ENUM ('FEHLALARM', 'OELEINSATZ', 'VERANSTALTUNG', 'VERMIETUNG', 'SONSTIGES'); + +-- AlterTable +ALTER TABLE "belegentwurf" ADD COLUMN "buchungId" UUID; + +-- CreateTable +CREATE TABLE "kassenkonto" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "bezeichnung" VARCHAR(120) NOT NULL, + "art" "KontoArt" NOT NULL DEFAULT 'BAR', + "iban" VARCHAR(34), + "bic" VARCHAR(11), + "anfangsbestand" DECIMAL(12,2) NOT NULL DEFAULT 0, + "bemerkung" VARCHAR(500), + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + "geloeschtAm" TIMESTAMPTZ(3), + + CONSTRAINT "kassenkonto_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "buchungskategorie" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "name" VARCHAR(120) NOT NULL, + "art" "BuchungsArt" NOT NULL, + "oberkategorieId" UUID, + "kontonummer" VARCHAR(20), + "bemerkung" VARCHAR(500), + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + "geloeschtAm" TIMESTAMPTZ(3), + + CONSTRAINT "buchungskategorie_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "buchung" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "kontoId" UUID NOT NULL, + "kategorieId" UUID NOT NULL, + "datum" DATE NOT NULL, + "art" "BuchungsArt" NOT NULL, + "betrag" DECIMAL(12,2) NOT NULL, + "verwendungszweck" VARCHAR(300) NOT NULL, + "belegnummer" VARCHAR(60), + "personId" UUID, + "lieferant" VARCHAR(200), + "gegenkontoId" UUID, + "belegDateiId" UUID, + "festgeschriebenAm" TIMESTAMPTZ(3), + "storniertVonId" UUID, + "erfasstVonId" UUID, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "buchung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "beitragssatz" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "bezeichnung" VARCHAR(120) NOT NULL, + "betrag" DECIMAL(12,2) NOT NULL, + "abteilung" VARCHAR(60), + "abJahr" INTEGER NOT NULL, + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "beitragssatz_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "beitrag" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "personId" UUID NOT NULL, + "jahr" INTEGER NOT NULL, + "satzId" UUID, + "betrag" DECIMAL(12,2) NOT NULL, + "stand" "BeitragStand" NOT NULL DEFAULT 'OFFEN', + "buchungId" UUID, + "bezahltAm" DATE, + "mahnstufe" INTEGER NOT NULL DEFAULT 0, + "gemahntAm" DATE, + "erlassGrund" VARCHAR(300), + "bemerkung" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "beitrag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rechnung" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "nummer" VARCHAR(20) NOT NULL, + "anlass" "RechnungsAnlass" NOT NULL DEFAULT 'SONSTIGES', + "stand" "RechnungStand" NOT NULL DEFAULT 'ENTWURF', + "empfaenger" VARCHAR(200) NOT NULL, + "empfaengerZusatz" VARCHAR(200), + "strasse" VARCHAR(200), + "plz" VARCHAR(10), + "ort" VARCHAR(120), + "einsatzId" UUID, + "datum" DATE NOT NULL, + "faelligAm" DATE NOT NULL, + "betrag" DECIMAL(12,2) NOT NULL, + "bemerkung" VARCHAR(1000), + "buchungId" UUID, + "bezahltAm" DATE, + "gemahntAm" DATE, + "mahnstufe" INTEGER NOT NULL DEFAULT 0, + "pdfDateiId" UUID, + "erstelltVonId" UUID, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "rechnung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rechnungsposition" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "rechnungId" UUID NOT NULL, + "reihenfolge" INTEGER NOT NULL, + "bezeichnung" VARCHAR(300) NOT NULL, + "menge" DECIMAL(12,3) NOT NULL DEFAULT 1, + "einheit" VARCHAR(20), + "einzelpreis" DECIMAL(12,2) NOT NULL, + "gesamtpreis" DECIMAL(12,2) NOT NULL, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "rechnungsposition_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "kassenabschluss" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "vonAm" DATE NOT NULL, + "bisAm" DATE NOT NULL, + "bestandLautBuch" DECIMAL(12,2) NOT NULL, + "gezaehlterBestand" DECIMAL(12,2) NOT NULL, + "differenz" DECIMAL(12,2) NOT NULL, + "erlaeuterung" VARCHAR(2000), + "prueferId" UUID, + "zweiterPrueferId" UUID, + "geprueftAm" DATE, + "berichtDateiId" UUID, + "festgeschriebenAm" TIMESTAMPTZ(3), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "kassenabschluss_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "abschlussposten" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "abschlussId" UUID NOT NULL, + "kontoId" UUID NOT NULL, + "bestandLautBuch" DECIMAL(12,2) NOT NULL, + "gezaehlterBestand" DECIMAL(12,2) NOT NULL, + "differenz" DECIMAL(12,2) NOT NULL, + "bemerkung" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "abschlussposten_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "kassenkonto_organisationId_aktiv_idx" ON "kassenkonto"("organisationId", "aktiv"); + +-- CreateIndex +CREATE UNIQUE INDEX "kassenkonto_organisationId_bezeichnung_key" ON "kassenkonto"("organisationId", "bezeichnung"); + +-- CreateIndex +CREATE INDEX "buchungskategorie_organisationId_art_idx" ON "buchungskategorie"("organisationId", "art"); + +-- CreateIndex +CREATE UNIQUE INDEX "buchungskategorie_organisationId_name_key" ON "buchungskategorie"("organisationId", "name"); + +-- CreateIndex +CREATE INDEX "buchung_organisationId_datum_idx" ON "buchung"("organisationId", "datum" DESC); + +-- CreateIndex +CREATE INDEX "buchung_organisationId_kontoId_datum_idx" ON "buchung"("organisationId", "kontoId", "datum"); + +-- CreateIndex +CREATE INDEX "buchung_organisationId_kategorieId_idx" ON "buchung"("organisationId", "kategorieId"); + +-- CreateIndex +CREATE INDEX "beitragssatz_organisationId_abJahr_idx" ON "beitragssatz"("organisationId", "abJahr"); + +-- CreateIndex +CREATE UNIQUE INDEX "beitragssatz_organisationId_abteilung_abJahr_key" ON "beitragssatz"("organisationId", "abteilung", "abJahr"); + +-- CreateIndex +CREATE INDEX "beitrag_organisationId_jahr_stand_idx" ON "beitrag"("organisationId", "jahr", "stand"); + +-- CreateIndex +CREATE UNIQUE INDEX "beitrag_personId_jahr_key" ON "beitrag"("personId", "jahr"); + +-- CreateIndex +CREATE INDEX "rechnung_organisationId_stand_idx" ON "rechnung"("organisationId", "stand"); + +-- CreateIndex +CREATE INDEX "rechnung_organisationId_datum_idx" ON "rechnung"("organisationId", "datum" DESC); + +-- CreateIndex +CREATE UNIQUE INDEX "rechnung_organisationId_nummer_key" ON "rechnung"("organisationId", "nummer"); + +-- CreateIndex +CREATE INDEX "rechnungsposition_organisationId_idx" ON "rechnungsposition"("organisationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "rechnungsposition_rechnungId_reihenfolge_key" ON "rechnungsposition"("rechnungId", "reihenfolge"); + +-- CreateIndex +CREATE INDEX "kassenabschluss_organisationId_bisAm_idx" ON "kassenabschluss"("organisationId", "bisAm" DESC); + +-- CreateIndex +CREATE INDEX "abschlussposten_organisationId_idx" ON "abschlussposten"("organisationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "abschlussposten_abschlussId_kontoId_key" ON "abschlussposten"("abschlussId", "kontoId"); + +-- AddForeignKey +ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_buchungId_fkey" FOREIGN KEY ("buchungId") REFERENCES "buchung"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kassenkonto" ADD CONSTRAINT "kassenkonto_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "buchungskategorie" ADD CONSTRAINT "buchungskategorie_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "buchungskategorie" ADD CONSTRAINT "buchungskategorie_oberkategorieId_fkey" FOREIGN KEY ("oberkategorieId") REFERENCES "buchungskategorie"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_kontoId_fkey" FOREIGN KEY ("kontoId") REFERENCES "kassenkonto"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_kategorieId_fkey" FOREIGN KEY ("kategorieId") REFERENCES "buchungskategorie"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_belegDateiId_fkey" FOREIGN KEY ("belegDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_storniertVonId_fkey" FOREIGN KEY ("storniertVonId") REFERENCES "buchung"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "beitragssatz" ADD CONSTRAINT "beitragssatz_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_satzId_fkey" FOREIGN KEY ("satzId") REFERENCES "beitragssatz"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_buchungId_fkey" FOREIGN KEY ("buchungId") REFERENCES "buchung"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_buchungId_fkey" FOREIGN KEY ("buchungId") REFERENCES "buchung"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_pdfDateiId_fkey" FOREIGN KEY ("pdfDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rechnungsposition" ADD CONSTRAINT "rechnungsposition_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rechnungsposition" ADD CONSTRAINT "rechnungsposition_rechnungId_fkey" FOREIGN KEY ("rechnungId") REFERENCES "rechnung"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kassenabschluss" ADD CONSTRAINT "kassenabschluss_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kassenabschluss" ADD CONSTRAINT "kassenabschluss_prueferId_fkey" FOREIGN KEY ("prueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kassenabschluss" ADD CONSTRAINT "kassenabschluss_zweiterPrueferId_fkey" FOREIGN KEY ("zweiterPrueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kassenabschluss" ADD CONSTRAINT "kassenabschluss_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "abschlussposten" ADD CONSTRAINT "abschlussposten_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "abschlussposten" ADD CONSTRAINT "abschlussposten_abschlussId_fkey" FOREIGN KEY ("abschlussId") REFERENCES "kassenabschluss"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "abschlussposten" ADD CONSTRAINT "abschlussposten_kontoId_fkey" FOREIGN KEY ("kontoId") REFERENCES "kassenkonto"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260904140000_kassenverwaltung_rls/migration.sql b/prisma/migrations/20260904140000_kassenverwaltung_rls/migration.sql new file mode 100644 index 0000000..d12d8ad --- /dev/null +++ b/prisma/migrations/20260904140000_kassenverwaltung_rls/migration.sql @@ -0,0 +1,306 @@ +-- ============================================================================= +-- Phase 9 — Mandantentrennung und die Regeln, die eine Kasse prüffähig machen +-- +-- Drei Dinge stehen hier und nicht im Anwendungscode, weil sie auch dann +-- gelten müssen, wenn jemand an der Anwendung vorbei schreibt: +-- +-- 1. **Eine festgeschriebene Buchung ist unveränderbar.** Ein Kassenbuch, +-- in dem man rückwirkend ändern kann, ist vor der Kassenprüfung wertlos. +-- 2. **Ein festgeschriebener Abschluss friert seinen Zeitraum ein.** Sonst +-- hätte der geprüfte Bestand am Tag nach der Prüfung eine andere Zahl. +-- 3. **Rechnerisch Unmögliches wird abgewiesen** — negative Beträge, ein +-- Ende vor dem Beginn, eine Buchung, die gleichzeitig an ein Mitglied und +-- an einen Lieferanten geht. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- Row Level Security +-- ----------------------------------------------------------------------------- +DO $$ +DECLARE + tabelle text; +BEGIN + FOREACH tabelle IN ARRAY ARRAY[ + 'kassenkonto', + 'buchungskategorie', + 'buchung', + 'beitragssatz', + 'beitrag', + 'rechnung', + 'rechnungsposition', + 'kassenabschluss', + 'abschlussposten' + ] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', tabelle); + + EXECUTE format('DROP POLICY IF EXISTS %I ON %I', tabelle || '_mandant', tabelle); + EXECUTE format( + 'CREATE POLICY %I ON %I FOR ALL ' + 'USING ("organisationId" = aktuelle_organisation()) ' + 'WITH CHECK ("organisationId" = aktuelle_organisation())', + tabelle || '_mandant', tabelle + ); + END LOOP; +END $$; + +-- ----------------------------------------------------------------------------- +-- Prüfbedingungen +-- ----------------------------------------------------------------------------- + +-- Der Betrag ist positiv; die Richtung steht in "art". Eine Buchung über +-- minus zwölf Euro wäre eine zweite Art, dasselbe auszudrücken — und die +-- Summenformel müsste dann beide beherrschen. +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_betrag_positiv" CHECK ("betrag" > 0); + +-- Entweder ein Mitglied oder ein Dritter. Beides zusammen wäre nicht +-- auswertbar: bei „wer hat wie viel bekommen" stünde derselbe Betrag zweimal. +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_gegenueber_eindeutig" CHECK ( + NOT ("personId" IS NOT NULL AND "lieferant" IS NOT NULL) +); + +-- Eine Umbuchung auf dasselbe Konto ist keine. +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_gegenkonto_verschieden" CHECK ( + "gegenkontoId" IS NULL OR "gegenkontoId" <> "kontoId" +); + +ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_betrag_nicht_negativ" CHECK ("betrag" >= 0); +ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_jahr_plausibel" CHECK ( + "jahr" BETWEEN 1900 AND 2200 +); +ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_mahnstufe_plausibel" CHECK ( + "mahnstufe" BETWEEN 0 AND 5 +); + +-- Ein erlassener Beitrag ohne Begründung ist in der Kassenprüfung eine offene +-- Frage. Lieber gleich hier verlangen als hinterher erklären. +ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_erlass_begruendet" CHECK ( + "stand" <> 'ERLASSEN' OR "erlassGrund" IS NOT NULL +); + +-- Bezahlt heißt: es gibt einen Tag, an dem gezahlt wurde. +ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_bezahlt_datiert" CHECK ( + "stand" <> 'BEZAHLT' OR "bezahltAm" IS NOT NULL +); + +ALTER TABLE "beitragssatz" ADD CONSTRAINT "beitragssatz_betrag_nicht_negativ" CHECK ( + "betrag" >= 0 +); + +ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_betrag_nicht_negativ" CHECK ("betrag" >= 0); +ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_faellig_nach_datum" CHECK ( + "faelligAm" >= "datum" +); + +ALTER TABLE "rechnungsposition" ADD CONSTRAINT "position_menge_positiv" CHECK ("menge" > 0); +ALTER TABLE "rechnungsposition" ADD CONSTRAINT "position_preis_nicht_negativ" CHECK ( + "einzelpreis" >= 0 AND "gesamtpreis" >= 0 +); + +ALTER TABLE "kassenabschluss" ADD CONSTRAINT "abschluss_zeitraum_sinnvoll" CHECK ( + "bisAm" >= "vonAm" +); + +-- Die Differenz ist keine freie Eingabe, sondern das Ergebnis. Wer sie von +-- Hand setzt, verdeckt genau das, was die Prüfung finden soll. +ALTER TABLE "kassenabschluss" ADD CONSTRAINT "abschluss_differenz_stimmt" CHECK ( + "differenz" = "gezaehlterBestand" - "bestandLautBuch" +); + +ALTER TABLE "abschlussposten" ADD CONSTRAINT "posten_differenz_stimmt" CHECK ( + "differenz" = "gezaehlterBestand" - "bestandLautBuch" +); + +-- Ein festgeschriebener Abschluss braucht Prüfer und Datum. Ohne beides ist +-- er kein Abschluss, sondern eine Notiz. +ALTER TABLE "kassenabschluss" ADD CONSTRAINT "abschluss_pruefung_vollstaendig" CHECK ( + "festgeschriebenAm" IS NULL + OR ("prueferId" IS NOT NULL AND "geprueftAm" IS NOT NULL) +); + +-- ----------------------------------------------------------------------------- +-- Suche und Auswertung +-- ----------------------------------------------------------------------------- + +-- Der Verwendungszweck ist das Feld, in dem gesucht wird — „wo war noch die +-- Rechnung von der Werkstatt". +CREATE INDEX IF NOT EXISTS "buchung_suche_zweck" + ON "buchung" USING gin ("verwendungszweck" gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS "buchung_suche_lieferant" + ON "buchung" USING gin ("lieferant" gin_trgm_ops); + +-- Offene Posten: die Liste, die der Kassier vor der Jahreshauptversammlung +-- braucht. +CREATE INDEX IF NOT EXISTS "beitrag_offen" + ON "beitrag" ("organisationId", "jahr") + WHERE "stand" IN ('OFFEN', 'GEMAHNT'); + +CREATE INDEX IF NOT EXISTS "rechnung_offen" + ON "rechnung" ("organisationId", "faelligAm") + WHERE "stand" IN ('VERSENDET', 'GEMAHNT'); + +-- Noch nicht festgeschriebene Buchungen — der Stapel, den jemand durchsieht. +CREATE INDEX IF NOT EXISTS "buchung_offen" + ON "buchung" ("organisationId", "datum") + WHERE "festgeschriebenAm" IS NULL; + +-- ----------------------------------------------------------------------------- +-- Rechte +-- ----------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') THEN + EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app'; + EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app'; + + EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app'; + EXECUTE 'REVOKE DELETE ON "benachrichtigung" FROM florian_app'; + EXECUTE 'REVOKE DELETE ON "ki_vorgang" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "wikiversion" FROM florian_app'; + + -- Ein festgeschriebener Abschluss lässt sich nicht wegräumen. Das + -- Löschen einzelner Buchungen bleibt möglich, solange sie nicht + -- festgeschrieben sind — den Rest erledigt der Trigger unten, weil + -- ein pauschales REVOKE auch den Entwurfsstapel unlöschbar machen + -- würde. + EXECUTE 'REVOKE DELETE ON "kassenabschluss" FROM florian_app'; + END IF; +END $$; + +-- ----------------------------------------------------------------------------- +-- Eine festgeschriebene Buchung ist unveränderbar +-- ----------------------------------------------------------------------------- + +-- Änderbar bleibt genau eines: der Verweis auf die Buchung, die diese hier +-- aufhebt. Eine Stornierung entsteht ja erst später — sie einzutragen ist +-- keine Änderung des Vorgangs, sondern seine Fortschreibung. +CREATE OR REPLACE FUNCTION buchung_unveraenderlich() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + -- Die eine Ausnahme ist der Aufbewahrungslauf, derselbe Schalter wie + -- beim Audit-Log und beim Pruefprotokoll. Ohne ihn liesse sich eine + -- Organisation nicht mehr loeschen -- und genau das verlangt die + -- DSGVO, wenn eine Wehr das System verlaesst. + IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN + RETURN OLD; + END IF; + + IF OLD."festgeschriebenAm" IS NOT NULL THEN + RAISE EXCEPTION + 'Diese Buchung ist seit % festgeschrieben und laesst sich nicht loeschen. ' + 'Korrekturen laufen ueber eine Gegenbuchung.', OLD."festgeschriebenAm"; + END IF; + RETURN OLD; + END IF; + + IF OLD."festgeschriebenAm" IS NULL THEN + RETURN NEW; + END IF; + + IF NEW."datum" IS DISTINCT FROM OLD."datum" + OR NEW."betrag" IS DISTINCT FROM OLD."betrag" + OR NEW."art" IS DISTINCT FROM OLD."art" + OR NEW."kontoId" IS DISTINCT FROM OLD."kontoId" + OR NEW."kategorieId" IS DISTINCT FROM OLD."kategorieId" + OR NEW."verwendungszweck" IS DISTINCT FROM OLD."verwendungszweck" + OR NEW."personId" IS DISTINCT FROM OLD."personId" + OR NEW."lieferant" IS DISTINCT FROM OLD."lieferant" + OR NEW."belegnummer" IS DISTINCT FROM OLD."belegnummer" + OR NEW."festgeschriebenAm" IS DISTINCT FROM OLD."festgeschriebenAm" THEN + RAISE EXCEPTION + 'Diese Buchung ist seit % festgeschrieben. Korrekturen laufen ueber eine ' + 'Gegenbuchung mit Verweis, nie durch Aendern.', OLD."festgeschriebenAm"; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS buchung_unveraenderlich_trigger ON "buchung"; +CREATE TRIGGER buchung_unveraenderlich_trigger + BEFORE UPDATE OR DELETE ON "buchung" + FOR EACH ROW + EXECUTE FUNCTION buchung_unveraenderlich(); + +-- ----------------------------------------------------------------------------- +-- Ein festgeschriebener Abschluss friert seinen Zeitraum ein +-- ----------------------------------------------------------------------------- + +-- Gilt für neue Buchungen und für das Verschieben vorhandener. Ohne die +-- zweite Hälfte könnte man eine Buchung nachträglich in einen geprüften +-- Zeitraum hineindatieren — und der geprüfte Bestand stimmte nicht mehr. +CREATE OR REPLACE FUNCTION buchung_zeitraum_offen() +RETURNS TRIGGER AS $$ +DECLARE + abschluss RECORD; +BEGIN + SELECT "vonAm", "bisAm", "geprueftAm" + INTO abschluss + FROM "kassenabschluss" + WHERE "organisationId" = NEW."organisationId" + AND "festgeschriebenAm" IS NOT NULL + AND NEW."datum" BETWEEN "vonAm" AND "bisAm" + LIMIT 1; + + IF FOUND THEN + RAISE EXCEPTION + 'Der Zeitraum vom % bis % ist mit dem Kassenabschluss vom % geprueft und ' + 'abgeschlossen. Fuer eine Korrektur bitte mit heutigem Datum gegenbuchen.', + abschluss."vonAm", abschluss."bisAm", abschluss."geprueftAm"; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS buchung_zeitraum_offen_trigger ON "buchung"; +CREATE TRIGGER buchung_zeitraum_offen_trigger + BEFORE INSERT OR UPDATE OF "datum" ON "buchung" + FOR EACH ROW + EXECUTE FUNCTION buchung_zeitraum_offen(); + +-- ----------------------------------------------------------------------------- +-- Ein festgeschriebener Abschluss bleibt, wie er ist +-- ----------------------------------------------------------------------------- +-- Aenderbar bleibt genau eines: der Verweis auf den erzeugten Bericht. Das PDF +-- entsteht im Hintergrund und damit erst nach dem Festschreiben — es +-- nachzutragen ist keine Aenderung des Abschlusses, sondern seine +-- Fertigstellung. Dieselbe Ausnahme wie beim Stornierungsverweis der Buchung. +CREATE OR REPLACE FUNCTION abschluss_unveraenderlich() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD."festgeschriebenAm" IS NULL THEN + RETURN NEW; + END IF; + + IF NEW."vonAm" IS NOT DISTINCT FROM OLD."vonAm" + AND NEW."bisAm" IS NOT DISTINCT FROM OLD."bisAm" + AND NEW."bestandLautBuch" IS NOT DISTINCT FROM OLD."bestandLautBuch" + AND NEW."gezaehlterBestand" IS NOT DISTINCT FROM OLD."gezaehlterBestand" + AND NEW."differenz" IS NOT DISTINCT FROM OLD."differenz" + AND NEW."erlaeuterung" IS NOT DISTINCT FROM OLD."erlaeuterung" + AND NEW."prueferId" IS NOT DISTINCT FROM OLD."prueferId" + AND NEW."zweiterPrueferId" IS NOT DISTINCT FROM OLD."zweiterPrueferId" + AND NEW."geprueftAm" IS NOT DISTINCT FROM OLD."geprueftAm" + AND NEW."festgeschriebenAm" IS NOT DISTINCT FROM OLD."festgeschriebenAm" THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION + 'Der Kassenabschluss vom % ist festgeschrieben. Zahlen und Pruefer lassen sich ' + 'nicht mehr aendern.', OLD."geprueftAm"; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS abschluss_unveraenderlich_trigger ON "kassenabschluss"; +CREATE TRIGGER abschluss_unveraenderlich_trigger + BEFORE UPDATE ON "kassenabschluss" + FOR EACH ROW + EXECUTE FUNCTION abschluss_unveraenderlich(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5bdf9df..3265df4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -234,6 +234,17 @@ model Organisation { diktate Diktat[] belegentwuerfe Belegentwurf[] + // Phase 9 — Kassenverwaltung + kassenkonten Kassenkonto[] + buchungskategorien Buchungskategorie[] + buchungen Buchung[] + beitragssaetze Beitragssatz[] + beitraege Beitrag[] + rechnungen Rechnung[] + rechnungspositionen Rechnungsposition[] + kassenabschluesse Kassenabschluss[] + abschlussposten Abschlussposten[] + @@index([aktiv]) @@map("organisation") } @@ -356,6 +367,14 @@ model Person { erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser") bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger") + // Phase 9 — Kassenverwaltung + buchungen Buchung[] @relation("BuchungPerson") + erfassteBuchungen Buchung[] @relation("BuchungErfasser") + beitraege Beitrag[] @relation("BeitragPerson") + gepruefteAbschluesse Kassenabschluss[] @relation("AbschlussPruefer") + zweitgepruefteAbschluesse Kassenabschluss[] @relation("AbschlussZweitpruefer") + erstellteRechnungen Rechnung[] @relation("RechnungErsteller") + /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe /// Person kann in zwei Wehren Mitglied sein. @@unique([organisationId, email]) @@ -602,6 +621,11 @@ model Datei { alsDiktataufnahme Diktat[] @relation("DiktatAufnahme") alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto") + // Phase 9 + alsBuchungsbeleg Buchung[] @relation("BuchungBeleg") + alsRechnungsPdf Rechnung[] @relation("RechnungPdf") + alsKassenpruefbericht Kassenabschluss[] @relation("AbschlussBericht") + @@unique([bucket, objektSchluessel]) @@index([organisationId, entitaet, entitaetId]) @@index([organisationId, kategorie]) @@ -3601,7 +3625,11 @@ model Belegentwurf { geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) + /// Die Buchung, die daraus geworden ist (Phase 9). Leer heißt: noch keine. + buchungId String? @db.Uuid + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull) fotoDatei Datei @relation("BelegentwurfFoto", fields: [fotoDateiId], references: [id], onDelete: Restrict) kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull) erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull) @@ -3611,3 +3639,435 @@ model Belegentwurf { @@index([organisationId, belegdatum]) @@map("belegentwurf") } + +// ============================================================================= +// Phase 9 — Kassenverwaltung +// +// Drei Festlegungen bestimmen alles Weitere. Sie stehen hier, weil sie sich +// im Schema abbilden und nicht bloß in der Anwendung durchgesetzt werden: +// +// 1. **Beträge sind DECIMAL(12,2), niemals Fließkomma.** 0,1 + 0,2 ist in +// binärer Fließkommadarstellung nicht 0,3, und eine Kasse, die sich um +// Cent verrechnet, ist keine Kasse. Zwölf Stellen reichen für zehn +// Milliarden — mehr als jede Wehr je bewegt. +// +// 2. **Eine festgeschriebene Buchung ist unveränderbar.** Korrekturen laufen +// über eine Gegenbuchung mit Verweis auf das Original. Das ist keine +// Förmlichkeit: ein Kassenbuch, in dem man rückwirkend ändern kann, ist +// vor der Kassenprüfung wertlos. Der Trigger in der Migration setzt es +// durch. +// +// 3. **Der Kassenabschluss friert seinen Zeitraum ein.** Danach nimmt die +// Buchungsmaske kein Datum daraus mehr an. Sonst hätte der geprüfte +// Bestand am Tag nach der Prüfung eine andere Zahl. +// +// Der Betrag ist immer **positiv**; die Richtung steht in `art`. Ein +// vorzeichenbehafteter Betrag wäre kürzer, aber jede Auswertung müsste dann +// darauf vertrauen, dass das Vorzeichen zur Kategorie passt — und beim ersten +// Tippfehler stimmt die Summe nicht mehr. +// ============================================================================= + +/// Welche Art von Konto. +enum KontoArt { + /// Die Barkasse im Gerätehaus. Wird bei der Prüfung gezählt. + BAR + /// Ein Bankkonto. Wird gegen den Auszug geprüft. + BANK + /// Rücklage für eine bestimmte Anschaffung — getrennt geführt, damit + /// niemand versehentlich das Geld für das neue Fahrzeug verbraucht. + RUECKLAGE +} + +/// Geht Geld ein oder hinaus. +enum BuchungsArt { + EINNAHME + AUSGABE +} + +/// Wie es um einen Beitrag steht. +enum BeitragStand { + OFFEN + BEZAHLT + /// Erlassen — bei Härtefällen, Ehrenmitgliedern, Jugendlichen. + ERLASSEN + GEMAHNT +} + +/// Wie es um eine Rechnung steht. +enum RechnungStand { + ENTWURF + VERSENDET + BEZAHLT + GEMAHNT + STORNIERT +} + +/// Woraus eine Rechnung an Dritte entsteht. +enum RechnungsAnlass { + /// Böswilliger oder fahrlässig ausgelöster Fehlalarm. + FEHLALARM + /// Ölspur, auslaufende Betriebsstoffe — nach Verursacherprinzip. + OELEINSATZ + /// Absicherung, Brandsicherheitswache bei einer Veranstaltung. + VERANSTALTUNG + /// Vermietung von Gerät oder Räumen. + VERMIETUNG + SONSTIGES +} + +/// Ein Konto der Wehr. +model Kassenkonto { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + bezeichnung String @db.VarChar(120) + art KontoArt @default(BAR) + + /// Nur bei Bankkonten. Wird in Auswertungen nie vollständig angezeigt. + iban String? @db.VarChar(34) + bic String? @db.VarChar(11) + + /// Der Bestand zu Beginn der Aufzeichnung. Alles Weitere ergibt sich aus + /// den Buchungen — er wird nie nachträglich „korrigiert". + anfangsbestand Decimal @default(0) @db.Decimal(12, 2) + + bemerkung String? @db.VarChar(500) + aktiv Boolean @default(true) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + geloeschtAm DateTime? @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + buchungen Buchung[] + posten Abschlussposten[] + + @@unique([organisationId, bezeichnung]) + @@index([organisationId, aktiv]) + @@map("kassenkonto") +} + +/// Wofür Geld ausgegeben oder eingenommen wurde. +/// +/// Zweistufig: eine Oberkategorie („Fahrzeuge") mit Unterkategorien +/// („Kraftstoff", „Reparatur"). Mehr Ebenen braucht eine Wehr nicht, und jede +/// weitere macht die Auswertung unlesbar. +model Buchungskategorie { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + name String @db.VarChar(120) + art BuchungsArt + + /// Die Oberkategorie. Leer heißt: das hier ist eine. + oberkategorieId String? @db.Uuid + + /// Nummer im Kontenrahmen der Gemeinde — für den Export an die Kämmerei. + kontonummer String? @db.VarChar(20) + + bemerkung String? @db.VarChar(500) + aktiv Boolean @default(true) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + geloeschtAm DateTime? @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + oberkategorie Buchungskategorie? @relation("Kategoriebaum", fields: [oberkategorieId], references: [id], onDelete: SetNull) + unterkategorien Buchungskategorie[] @relation("Kategoriebaum") + buchungen Buchung[] + + @@unique([organisationId, name]) + @@index([organisationId, art]) + @@map("buchungskategorie") +} + +/// Eine Buchung. +/// +/// Der Betrag ist positiv, die Richtung steht in `art`. Festgeschrieben heißt: +/// unveränderbar und nicht löschbar — die Datenbank hält dagegen. Wer sich +/// vertan hat, bucht dagegen; die Gegenbuchung verweist auf das Original, und +/// beide bleiben stehen. +model Buchung { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + kontoId String @db.Uuid + kategorieId String @db.Uuid + + /// Das Buchungsdatum, nicht der Zeitpunkt der Erfassung. Danach richtet + /// sich, in welchen Abschluss die Buchung fällt. + datum DateTime @db.Date + art BuchungsArt + betrag Decimal @db.Decimal(12, 2) + + verwendungszweck String @db.VarChar(300) + belegnummer String? @db.VarChar(60) + + /// Wer das Geld bekommen oder gegeben hat. Entweder eine Person der Wehr + /// **oder** ein Dritter als Text — nie beides. + personId String? @db.Uuid + lieferant String? @db.VarChar(200) + + /// Bei einer Umbuchung zwischen zwei eigenen Konten das Gegenkonto. + gegenkontoId String? @db.Uuid + + belegDateiId String? @db.Uuid + + /// Festgeschrieben: ab hier unveränderbar. + festgeschriebenAm DateTime? @db.Timestamptz(3) + + /// Die Buchung, die diese hier aufhebt — und umgekehrt. + storniertVonId String? @db.Uuid + + erfasstVonId String? @db.Uuid + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + konto Kassenkonto @relation(fields: [kontoId], references: [id], onDelete: Restrict) + kategorie Buchungskategorie @relation(fields: [kategorieId], references: [id], onDelete: Restrict) + person Person? @relation("BuchungPerson", fields: [personId], references: [id], onDelete: SetNull) + erfasstVon Person? @relation("BuchungErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull) + belegDatei Datei? @relation("BuchungBeleg", fields: [belegDateiId], references: [id], onDelete: SetNull) + storniertVon Buchung? @relation("Stornierung", fields: [storniertVonId], references: [id], onDelete: SetNull) + storniert Buchung[] @relation("Stornierung") + beitraege Beitrag[] + rechnungen Rechnung[] + belegentwurf Belegentwurf[] + + @@index([organisationId, datum(sort: Desc)]) + @@index([organisationId, kontoId, datum]) + @@index([organisationId, kategorieId]) + @@map("buchung") +} + +/// Was ein Mitglied im Jahr zahlt. +/// +/// Je Abteilung ein Satz: Aktive zahlen anderes als Jugend, Ehrenmitglieder +/// oft nichts. Der Satz gilt ab einem Jahr und wird nicht rückwirkend +/// geändert — sonst stimmen die Beiträge vergangener Jahre nicht mehr. +model Beitragssatz { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + bezeichnung String @db.VarChar(120) + betrag Decimal @db.Decimal(12, 2) + + /// Für welche Abteilung. Leer heißt: für alle, die keinen eigenen Satz haben. + abteilung String? @db.VarChar(60) + + /// Ab welchem Jahr er gilt. + abJahr Int + + aktiv Boolean @default(true) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + beitraege Beitrag[] + + @@unique([organisationId, abteilung, abJahr]) + @@index([organisationId, abJahr]) + @@map("beitragssatz") +} + +/// Der Beitrag einer Person für ein Jahr. +model Beitrag { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + personId String @db.Uuid + jahr Int + + satzId String? @db.Uuid + betrag Decimal @db.Decimal(12, 2) + + stand BeitragStand @default(OFFEN) + + /// Die Buchung, mit der bezahlt wurde. Erst sie macht aus „bezahlt" eine + /// Zahl in der Kasse. + buchungId String? @db.Uuid + bezahltAm DateTime? @db.Date + + /// Wie oft gemahnt wurde. Null heißt: noch nicht. + mahnstufe Int @default(0) + gemahntAm DateTime? @db.Date + /// Bei Erlass: warum. Ein erlassener Beitrag ohne Begründung ist in der + /// Kassenprüfung eine offene Frage. + erlassGrund String? @db.VarChar(300) + + bemerkung String? @db.VarChar(500) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + person Person @relation("BeitragPerson", fields: [personId], references: [id], onDelete: Cascade) + satz Beitragssatz? @relation(fields: [satzId], references: [id], onDelete: SetNull) + buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull) + + @@unique([personId, jahr]) + @@index([organisationId, jahr, stand]) + @@map("beitrag") +} + +/// Eine Rechnung an Dritte. +/// +/// Fehlalarm, Ölspur, Absicherung einer Veranstaltung. Nummer aus dem +/// Nummernkreis (`JJJJ-NNNN`), PDF über Gotenberg. +model Rechnung { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + nummer String @db.VarChar(20) + anlass RechnungsAnlass @default(SONSTIGES) + stand RechnungStand @default(ENTWURF) + + /// An wen. Bewusst als Text und nicht als Verweis: der Empfänger ist in + /// aller Regel niemand, den die Wehr dauerhaft führt. + empfaenger String @db.VarChar(200) + empfaengerZusatz String? @db.VarChar(200) + strasse String? @db.VarChar(200) + plz String? @db.VarChar(10) + ort String? @db.VarChar(120) + + /// Worauf sie sich bezieht. + einsatzId String? @db.Uuid + + datum DateTime @db.Date + faelligAm DateTime @db.Date + + /// Summe der Positionen. Wird beim Speichern gerechnet, nicht eingegeben. + betrag Decimal @db.Decimal(12, 2) + + bemerkung String? @db.VarChar(1000) + + /// Die Buchung des Zahlungseingangs. + buchungId String? @db.Uuid + bezahltAm DateTime? @db.Date + gemahntAm DateTime? @db.Date + mahnstufe Int @default(0) + + pdfDateiId String? @db.Uuid + + erstelltVonId String? @db.Uuid + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull) + pdfDatei Datei? @relation("RechnungPdf", fields: [pdfDateiId], references: [id], onDelete: SetNull) + erstelltVon Person? @relation("RechnungErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull) + positionen Rechnungsposition[] + + @@unique([organisationId, nummer]) + @@index([organisationId, stand]) + @@index([organisationId, datum(sort: Desc)]) + @@map("rechnung") +} + +/// Eine Zeile auf einer Rechnung. +model Rechnungsposition { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + rechnungId String @db.Uuid + + reihenfolge Int + bezeichnung String @db.VarChar(300) + /// Menge mit drei Nachkommastellen — Einsatzstunden werden viertelstündlich + /// abgerechnet, und 0,25 muss genau sein. + menge Decimal @default(1) @db.Decimal(12, 3) + einheit String? @db.VarChar(20) + einzelpreis Decimal @db.Decimal(12, 2) + /// Menge × Einzelpreis, beim Speichern gerechnet. + gesamtpreis Decimal @db.Decimal(12, 2) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + rechnung Rechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade) + + @@unique([rechnungId, reihenfolge]) + @@index([organisationId]) + @@map("rechnungsposition") +} + +/// Ein Kassenabschluss. +/// +/// Der Vorgang, für den es die ganze Kasse gibt: zwei Kassenprüfer setzen sich +/// hin, zählen das Bargeld, vergleichen die Kontoauszüge und unterschreiben. +/// Danach ist der Zeitraum zu — die Buchungsmaske nimmt kein Datum daraus mehr +/// an. +/// +/// Zwei Prüfer, weil es die Satzung fast jeder Wehr so vorsieht. Der zweite +/// ist trotzdem optional: eine Wehr, die gerade nur einen hat, soll deshalb +/// nicht ohne Abschluss dastehen. +model Kassenabschluss { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + vonAm DateTime @db.Date + bisAm DateTime @db.Date + + /// Summe über alle Konten, aus den Buchungen gerechnet. + bestandLautBuch Decimal @db.Decimal(12, 2) + /// Was tatsächlich gezählt wurde. + gezaehlterBestand Decimal @db.Decimal(12, 2) + /// gezaehlt minus laut Buch. Positiv heißt: es war mehr da. + differenz Decimal @db.Decimal(12, 2) + + /// Eine Differenz ohne Erklärung ist der Punkt, an dem eine Kassenprüfung + /// hängen bleibt. + erlaeuterung String? @db.VarChar(2000) + + prueferId String? @db.Uuid + zweiterPrueferId String? @db.Uuid + geprueftAm DateTime? @db.Date + + berichtDateiId String? @db.Uuid + + /// Festgeschrieben: ab hier ist der Zeitraum zu. + festgeschriebenAm DateTime? @db.Timestamptz(3) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + pruefer Person? @relation("AbschlussPruefer", fields: [prueferId], references: [id], onDelete: SetNull) + zweiterPruefer Person? @relation("AbschlussZweitpruefer", fields: [zweiterPrueferId], references: [id], onDelete: SetNull) + berichtDatei Datei? @relation("AbschlussBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull) + posten Abschlussposten[] + + @@index([organisationId, bisAm(sort: Desc)]) + @@map("kassenabschluss") +} + +/// Was auf einem Konto zum Abschluss stand. +/// +/// Je Konto eine Zeile: der Kassenprüfer zählt das Bargeld und liest den +/// Kontostand vom Auszug ab. Eine einzelne Summe über alles würde verbergen, +/// auf welchem Konto die Differenz sitzt. +model Abschlussposten { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + abschlussId String @db.Uuid + kontoId String @db.Uuid + + bestandLautBuch Decimal @db.Decimal(12, 2) + gezaehlterBestand Decimal @db.Decimal(12, 2) + differenz Decimal @db.Decimal(12, 2) + + bemerkung String? @db.VarChar(500) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + abschluss Kassenabschluss @relation(fields: [abschlussId], references: [id], onDelete: Cascade) + konto Kassenkonto @relation(fields: [kontoId], references: [id], onDelete: Restrict) + + @@unique([abschlussId, kontoId]) + @@index([organisationId]) + @@map("abschlussposten") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index e633df2..605cbda 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -21,6 +21,9 @@ * Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed. * Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen, * Atemschutz-Merkblatt, Schlauchpflege und Alarmierungsordnung. + * Ab Phase 9 dazu: das Kassenjahr — drei Konten, der Standardkontenrahmen, + * gut dreißig Buchungen über zwei Jahre, die Mitgliedsbeiträge mit Rückständen, + * zwei Rechnungen und die festgeschriebene Kassenprüfung des Vorjahres. * * Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang, * der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt @@ -36,11 +39,14 @@ import { Alarmierungsart, AnwesenheitAnlass, AusweisTokenTyp, + BeitragStand, + BuchungsArt, ChecklistenpunktTyp, EinsatzFunktion, EinsatzKategorie, EinsatzStatus, Dienstgrad, + KontoArt, DruckArt, EreignisArt, FahrtZweck, @@ -58,6 +64,8 @@ import { PruefungStatus, QualifikationsArt, RolleSchluessel, + RechnungsAnlass, + RechnungStand, SignaturRolle, HydrantStatus, HydrantTyp, @@ -3247,6 +3255,648 @@ async function wikiAnlegen(organisationId: string): Promise { ); } +// ----------------------------------------------------------------------------- +// Phase 9 — Kassenverwaltung +// +// Ein Kassenjahr, wie es eine Wehr wirklich hat: drei Konten, der +// Standardkontenrahmen, gut dreißig Buchungen über zwei Jahre, die +// Mitgliedsbeiträge, zwei Rechnungen und die abgeschlossene Kassenprüfung +// des Vorjahres. +// +// Die Reihenfolge ist hier nicht beliebig, und das ist kein Zufall des +// Skripts, sondern die Absicherung selbst: sobald der Abschluss für das +// Vorjahr festgeschrieben ist, lehnt ein Trigger jede weitere Buchung mit +// einem Datum in diesem Zeitraum ab. Erst die Buchungen, dann der Abschluss — +// wer es umdreht, bekommt vom Seed dieselbe Fehlermeldung wie der Kassier, +// der drei Wochen nach der Prüfung noch eine Quittung findet. +// ----------------------------------------------------------------------------- + +async function kassendatenAnlegen(organisationId: string): Promise { + const vorhanden = await prisma.kassenkonto.count({ where: { organisationId } }); + if (vorhanden > 0) { + log("Kassendaten bereits vorhanden — übersprungen."); + return; + } + + const { STANDARDKATEGORIEN } = await import("../src/lib/kasse/vorschlag"); + + const jahr = new Date().getUTCFullYear(); + const vorjahr = jahr - 1; + + // --- Konten --------------------------------------------------------------- + + const bar = await prisma.kassenkonto.create({ + data: { + organisationId, + bezeichnung: "Barkasse", + art: KontoArt.BAR, + anfangsbestand: "250.00", + bemerkung: "Kassette im Kommandantenzimmer. Wechselgeld für Feste.", + }, + }); + + const giro = await prisma.kassenkonto.create({ + data: { + organisationId, + bezeichnung: "Girokonto Raiffeisenbank", + art: KontoArt.BANK, + iban: "DE02701500000000594937", + bic: "SSKMDEMM", + anfangsbestand: "4800.00", + }, + }); + + const ruecklage = await prisma.kassenkonto.create({ + data: { + organisationId, + bezeichnung: "Rücklage Fahrzeugbeschaffung", + art: KontoArt.RUECKLAGE, + iban: "DE02120300000000202051", + anfangsbestand: "12000.00", + bemerkung: "Angespart für den Ersatz des MTW. Wird nicht laufend bewegt.", + }, + }); + + // --- Kontenrahmen --------------------------------------------------------- + // + // Zwei Durchgänge: erst die Oberkategorien, dann die Unterkategorien mit + // ihrem Verweis. Anders ginge es nicht, der Baum verweist auf sich selbst. + + const kategorien = new Map(); + + for (const eintrag of STANDARDKATEGORIEN.filter((k) => !k.oberkategorie)) { + const angelegt = await prisma.buchungskategorie.create({ + data: { organisationId, name: eintrag.name, art: eintrag.art }, + }); + kategorien.set(eintrag.name, angelegt.id); + } + + for (const eintrag of STANDARDKATEGORIEN.filter((k) => k.oberkategorie)) { + const angelegt = await prisma.buchungskategorie.create({ + data: { + organisationId, + name: eintrag.name, + art: eintrag.art, + oberkategorieId: kategorien.get(eintrag.oberkategorie!) ?? null, + }, + }); + kategorien.set(eintrag.name, angelegt.id); + } + + const kategorie = (name: string): string => { + const id = kategorien.get(name); + if (!id) throw new Error(`Kategorie fehlt im Standardkontenrahmen: ${name}`); + return id; + }; + + const kassier = await prisma.person.findFirst({ + where: { + organisationId, + rollen: { some: { rolle: { schluessel: RolleSchluessel.KASSIER } } }, + }, + select: { id: true }, + }); + + // --- Buchungen ------------------------------------------------------------ + // + // Das Vorjahr vollständig und festgeschrieben, das laufende Jahr bis in den + // Sommer und noch offen. So zeigt die Ansicht beide Zustände: das + // geschlossene Buch und das, in dem noch gearbeitet wird. + + type Zeile = { + konto: string; + monat: number; + tag: number; + art: BuchungsArt; + betrag: string; + kategorie: string; + zweck: string; + gegenueber?: string; + beleg?: string; + }; + + const vorjahrszeilen: Zeile[] = [ + { + konto: giro.id, + monat: 1, + tag: 15, + art: BuchungsArt.EINNAHME, + betrag: "6500.00", + kategorie: "Zuschüsse", + zweck: "Gemeindezuschuss laufender Betrieb", + gegenueber: "Gemeinde Musterhausen", + beleg: "Z-2024-01", + }, + { + konto: giro.id, + monat: 2, + tag: 3, + art: BuchungsArt.AUSGABE, + betrag: "1284.50", + kategorie: "Versicherungen", + zweck: "Jahresprämie Fahrzeug- und Unfallversicherung", + gegenueber: "Bayerische Versicherungskammer", + }, + { + konto: giro.id, + monat: 3, + tag: 12, + art: BuchungsArt.AUSGABE, + betrag: "412.90", + kategorie: "Kraftstoff", + zweck: "Diesel LF 20, Sammelrechnung Februar", + gegenueber: "Tankstelle Musterhausen", + }, + { + konto: giro.id, + monat: 4, + tag: 8, + art: BuchungsArt.AUSGABE, + betrag: "2340.00", + kategorie: "Atemschutz", + zweck: "Zwei Pressluftatmer, Ersatzbeschaffung", + gegenueber: "Dräger Safety", + beleg: "RE-2024-114", + }, + { + konto: giro.id, + monat: 5, + tag: 22, + art: BuchungsArt.AUSGABE, + betrag: "868.00", + kategorie: "Prüfungen", + zweck: "Wiederkehrende Prüfung Drehleiter", + gegenueber: "TÜV Süd", + }, + { + konto: bar.id, + monat: 6, + tag: 15, + art: BuchungsArt.EINNAHME, + betrag: "3860.00", + kategorie: "Veranstaltungen", + zweck: "Maifest, Getränke- und Speisenverkauf", + }, + { + konto: bar.id, + monat: 6, + tag: 15, + art: BuchungsArt.AUSGABE, + betrag: "1420.60", + kategorie: "Verpflegung", + zweck: "Maifest, Einkauf Getränke und Fleisch", + gegenueber: "Getränke Hofmann", + }, + { + konto: giro.id, + monat: 7, + tag: 2, + art: BuchungsArt.AUSGABE, + betrag: "1150.00", + kategorie: "Schutzkleidung", + zweck: "Vier Einsatzjacken, Ersatz nach Verschleiß", + gegenueber: "Feuerwehrbedarf Barth", + }, + { + konto: giro.id, + monat: 9, + tag: 18, + art: BuchungsArt.AUSGABE, + betrag: "624.00", + kategorie: "Ausbildung", + zweck: "Lehrgangsgebühren Maschinistenlehrgang", + gegenueber: "Landkreis Musterkreis", + }, + { + konto: giro.id, + monat: 10, + tag: 30, + art: BuchungsArt.AUSGABE, + betrag: "398.20", + kategorie: "Wasser und Strom", + zweck: "Abschlagszahlung Strom Gerätehaus", + gegenueber: "Stadtwerke Musterhausen", + }, + { + konto: giro.id, + monat: 11, + tag: 14, + art: BuchungsArt.EINNAHME, + betrag: "780.00", + kategorie: "Spenden", + zweck: "Spenden aus der Haussammlung", + }, + { + konto: giro.id, + monat: 12, + tag: 20, + art: BuchungsArt.AUSGABE, + betrag: "36.00", + kategorie: "Bankgebühren", + zweck: "Kontoführung viertes Quartal", + }, + ]; + + const laufendeZeilen: Zeile[] = [ + { + konto: giro.id, + monat: 1, + tag: 20, + art: BuchungsArt.EINNAHME, + betrag: "6500.00", + kategorie: "Zuschüsse", + zweck: "Gemeindezuschuss laufender Betrieb", + gegenueber: "Gemeinde Musterhausen", + }, + { + konto: giro.id, + monat: 2, + tag: 5, + art: BuchungsArt.AUSGABE, + betrag: "1341.80", + kategorie: "Versicherungen", + zweck: "Jahresprämie Fahrzeug- und Unfallversicherung", + gegenueber: "Bayerische Versicherungskammer", + }, + { + konto: giro.id, + monat: 2, + tag: 28, + art: BuchungsArt.AUSGABE, + betrag: "487.40", + kategorie: "Kraftstoff", + zweck: "Diesel LF 20 und MTW, Sammelrechnung Februar", + gegenueber: "Tankstelle Musterhausen", + }, + { + konto: giro.id, + monat: 3, + tag: 14, + art: BuchungsArt.AUSGABE, + betrag: "1890.00", + kategorie: "Reparatur", + zweck: "Kupplung MTW, Werkstattrechnung", + gegenueber: "Werkstatt Müller GmbH", + beleg: "RE-4471", + }, + { + konto: giro.id, + monat: 4, + tag: 3, + art: BuchungsArt.AUSGABE, + betrag: "212.00", + kategorie: "Verwaltung", + zweck: "Büromaterial und Druckerpatronen", + gegenueber: "Schreibwaren Ludwig", + }, + { + konto: bar.id, + monat: 5, + tag: 1, + art: BuchungsArt.EINNAHME, + betrag: "4120.00", + kategorie: "Veranstaltungen", + zweck: "Maifest, Getränke- und Speisenverkauf", + }, + { + konto: bar.id, + monat: 5, + tag: 1, + art: BuchungsArt.AUSGABE, + betrag: "1588.30", + kategorie: "Verpflegung", + zweck: "Maifest, Einkauf Getränke und Fleisch", + gegenueber: "Getränke Hofmann", + }, + { + konto: bar.id, + monat: 5, + tag: 6, + art: BuchungsArt.AUSGABE, + betrag: "2500.00", + kategorie: "Verwaltung", + zweck: "Einzahlung Barkasse auf das Girokonto nach dem Maifest", + }, + { + konto: giro.id, + monat: 5, + tag: 6, + art: BuchungsArt.EINNAHME, + betrag: "2500.00", + kategorie: "Veranstaltungen", + zweck: "Einzahlung aus der Barkasse nach dem Maifest", + }, + { + konto: giro.id, + monat: 6, + tag: 11, + art: BuchungsArt.AUSGABE, + betrag: "742.00", + kategorie: "Prüfungen", + zweck: "Schlauchprüfung, 40 Stück B und C", + gegenueber: "Schlauchpflege Nordbayern", + }, + { + konto: giro.id, + monat: 6, + tag: 25, + art: BuchungsArt.AUSGABE, + betrag: "36.00", + kategorie: "Bankgebühren", + zweck: "Kontoführung zweites Quartal", + }, + { + konto: ruecklage.id, + monat: 7, + tag: 1, + art: BuchungsArt.EINNAHME, + betrag: "3000.00", + kategorie: "Zuschüsse", + zweck: "Jährliche Zuführung zur Fahrzeugrücklage", + gegenueber: "Gemeinde Musterhausen", + }, + ]; + + async function buchungAnlegen(zeile: Zeile, festgeschrieben: boolean, buchungsjahr: number) { + return prisma.buchung.create({ + data: { + organisationId, + kontoId: zeile.konto, + kategorieId: kategorie(zeile.kategorie), + datum: datum(buchungsjahr, zeile.monat, zeile.tag), + art: zeile.art, + betrag: zeile.betrag, + verwendungszweck: zeile.zweck, + lieferant: zeile.gegenueber ?? null, + belegnummer: zeile.beleg ?? null, + erfasstVonId: kassier?.id ?? null, + festgeschriebenAm: festgeschrieben ? datum(vorjahr + 1, 2, 10) : null, + }, + }); + } + + for (const zeile of vorjahrszeilen) { + await buchungAnlegen(zeile, true, vorjahr); + } + for (const zeile of laufendeZeilen) { + await buchungAnlegen(zeile, false, jahr); + } + + // --- Beiträge ------------------------------------------------------------- + + const satzAktive = await prisma.beitragssatz.create({ + data: { + organisationId, + bezeichnung: "Aktive und Passive", + betrag: "25.00", + abteilung: null, + abJahr: vorjahr, + }, + }); + + const satzJugend = await prisma.beitragssatz.create({ + data: { + organisationId, + bezeichnung: "Jugendfeuerwehr", + betrag: "10.00", + abteilung: "Jugendfeuerwehr", + abJahr: vorjahr, + }, + }); + + const mitglieder = await prisma.person.findMany({ + where: { organisationId, aktiv: true, austrittsdatum: null }, + orderBy: { mitgliedsnummer: "asc" }, + select: { id: true, abteilung: true }, + }); + + const beitragsKategorie = kategorie("Mitgliedsbeiträge"); + + // Die meisten haben bezahlt, zwei stehen offen, einer ist gemahnt, einem + // wurde erlassen. Das ist die Verteilung, die eine Wehr im Herbst wirklich + // vor sich hat — und der Grund, warum es die Rückstandsliste gibt. + for (const [stelle, person] of mitglieder.entries()) { + const jugend = person.abteilung === "Jugendfeuerwehr"; + const satz = jugend ? satzJugend : satzAktive; + const betrag = jugend ? "10.00" : "25.00"; + + let buchungId: string | null = null; + let stand: BeitragStand = BeitragStand.BEZAHLT; + let bezahltAm: Date | null = datum(jahr, 2, 12 + (stelle % 10)); + let mahnstufe = 0; + let gemahntAm: Date | null = null; + let erlassGrund: string | null = null; + + if (stelle % 7 === 3) { + stand = BeitragStand.OFFEN; + bezahltAm = null; + } else if (stelle % 11 === 5) { + stand = BeitragStand.GEMAHNT; + bezahltAm = null; + mahnstufe = 1; + gemahntAm = datum(jahr, 6, 30); + } else if (stelle % 13 === 9) { + stand = BeitragStand.ERLASSEN; + bezahltAm = null; + erlassGrund = "Längere Erkrankung, Beschluss der Kommandantschaft vom 12.03."; + } + + if (stand === BeitragStand.BEZAHLT && bezahltAm) { + const buchung = await prisma.buchung.create({ + data: { + organisationId, + kontoId: giro.id, + kategorieId: beitragsKategorie, + datum: bezahltAm, + art: BuchungsArt.EINNAHME, + betrag, + verwendungszweck: `Mitgliedsbeitrag ${jahr}`, + personId: person.id, + erfasstVonId: kassier?.id ?? null, + }, + }); + buchungId = buchung.id; + } + + await prisma.beitrag.create({ + data: { + organisationId, + personId: person.id, + jahr, + satzId: satz.id, + betrag, + stand, + buchungId, + bezahltAm, + mahnstufe, + gemahntAm, + erlassGrund, + }, + }); + } + + // --- Rechnungen ----------------------------------------------------------- + + const kostenersatz = kategorie("Kostenersatz"); + + const fehlalarmBuchung = await prisma.buchung.create({ + data: { + organisationId, + kontoId: giro.id, + kategorieId: kostenersatz, + datum: datum(jahr, 4, 28), + art: BuchungsArt.EINNAHME, + betrag: "612.00", + verwendungszweck: `Zahlungseingang Rechnung ${jahr}-0001`, + lieferant: "Möbelhaus Sonnleitner GmbH", + erfasstVonId: kassier?.id ?? null, + }, + }); + + await prisma.rechnung.create({ + data: { + organisationId, + nummer: `${jahr}-0001`, + anlass: RechnungsAnlass.FEHLALARM, + stand: RechnungStand.BEZAHLT, + empfaenger: "Möbelhaus Sonnleitner GmbH", + strasse: "Industriestraße 14", + plz: "91315", + ort: "Musterhausen", + datum: datum(jahr, 3, 28), + faelligAm: datum(jahr, 4, 25), + betrag: "612.00", + bemerkung: + "Fehlalarm der Brandmeldeanlage am 26.03., verursacht durch Staub bei Umbauarbeiten. " + + "Kostenersatz nach Art. 28 Abs. 2 Nr. 4 BayFwG.", + buchungId: fehlalarmBuchung.id, + bezahltAm: datum(jahr, 4, 28), + erstelltVonId: kassier?.id ?? null, + positionen: { + create: [ + { + organisationId, + reihenfolge: 0, + bezeichnung: "Einsatz LF 20, angefangene Stunde", + menge: "1", + einheit: "Std.", + einzelpreis: "396.00", + gesamtpreis: "396.00", + }, + { + organisationId, + reihenfolge: 1, + bezeichnung: "Einsatzkräfte, je angefangene Stunde", + menge: "9", + einheit: "Pers.", + einzelpreis: "24.00", + gesamtpreis: "216.00", + }, + ], + }, + }, + }); + + await prisma.rechnung.create({ + data: { + organisationId, + nummer: `${jahr}-0002`, + anlass: RechnungsAnlass.VERMIETUNG, + stand: RechnungStand.VERSENDET, + empfaenger: "Trachtenverein Almrausch Musterhausen e. V.", + empfaengerZusatz: "z. Hd. Frau Vorstand Berger", + strasse: "Dorfplatz 2", + plz: "91315", + ort: "Musterhausen", + datum: datum(jahr, 6, 20), + faelligAm: datum(jahr, 7, 18), + betrag: "180.00", + bemerkung: "Überlassung des Schulungsraums für die Jahresversammlung am 14.06.", + erstelltVonId: kassier?.id ?? null, + positionen: { + create: [ + { + organisationId, + reihenfolge: 0, + bezeichnung: "Nutzung Schulungsraum, Tagespauschale", + menge: "1", + einheit: "Tag", + einzelpreis: "150.00", + gesamtpreis: "150.00", + }, + { + organisationId, + reihenfolge: 1, + bezeichnung: "Reinigung", + menge: "1", + einheit: "pauschal", + einzelpreis: "30.00", + gesamtpreis: "30.00", + }, + ], + }, + }, + }); + + // --- Kassenprüfung des Vorjahres ----------------------------------------- + // + // Zuletzt, und das ist der Punkt: ab hier ist der Zeitraum zu. Jede weitere + // Buchung mit einem Datum aus dem Vorjahr wird von der Datenbank abgelehnt. + + const pruefer = await prisma.person.findMany({ + where: { organisationId, aktiv: true, austrittsdatum: null }, + orderBy: { mitgliedsnummer: "desc" }, + take: 2, + select: { id: true }, + }); + + // Die Zahlen sind nicht erfunden, sondern die Summe der Buchungen oben: + // Barkasse 250,00 + 3.860,00 − 1.420,60, Giro 4.800,00 + 7.280,00 − 7.113,60, + // Rücklage unberührt. Ein Seed mit erfundenen Beständen wäre genau der + // Fall, den die Kassenprüfung finden soll. + const vorjahrsposten = [ + { konto: bar.id, buch: "2689.40", gezaehlt: "2686.20" }, + { konto: giro.id, buch: "4966.40", gezaehlt: "4966.40" }, + { konto: ruecklage.id, buch: "12000.00", gezaehlt: "12000.00" }, + ]; + + const abschluss = await prisma.kassenabschluss.create({ + data: { + organisationId, + vonAm: datum(vorjahr, 1, 1), + bisAm: datum(vorjahr, 12, 31), + bestandLautBuch: "19655.80", + gezaehlterBestand: "19652.60", + differenz: "-3.20", + erlaeuterung: + "In der Barkasse fehlen 3,20 €. Zurückzuführen auf das Maifest: das Wechselgeld " + + "wurde am Abend gezählt, ein Beleg über den Nachkauf von Eis fehlt. Die Prüfer " + + "halten den Betrag für erklärbar und schlagen Entlastung vor.", + prueferId: pruefer[0]?.id ?? null, + zweiterPrueferId: pruefer[1]?.id ?? null, + geprueftAm: datum(vorjahr + 1, 2, 8), + festgeschriebenAm: datum(vorjahr + 1, 2, 10), + posten: { + create: vorjahrsposten.map((posten) => ({ + organisationId, + kontoId: posten.konto, + bestandLautBuch: posten.buch, + gezaehlterBestand: posten.gezaehlt, + differenz: (Number(posten.gezaehlt) - Number(posten.buch)).toFixed(2), + })), + }, + }, + }); + + log( + `Kasse: 3 Konten, ${STANDARDKATEGORIEN.length} Kategorien, ` + + `${vorjahrszeilen.length + laufendeZeilen.length} Buchungen, ` + + `${mitglieder.length} Beiträge, 2 Rechnungen und die Kassenprüfung ${vorjahr} ` + + `(festgeschrieben — der Zeitraum ist zu, Abschluss ${abschluss.id.slice(0, 8)})`, + ); +} + + // ----------------------------------------------------------------------------- // Ablauf // ----------------------------------------------------------------------------- @@ -3293,6 +3943,9 @@ async function main(): Promise { // Phase 8 — Lokale KI await wikiAnlegen(organisation.id); + // Phase 9 — Kassenverwaltung + await kassendatenAnlegen(organisation.id); + log("Fertig."); log(""); log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index e4c5091..82f25d9 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -256,6 +256,38 @@ const AUFNAHMEN: Aufnahme[] = [ beschreibung: "Eine Wiki-Seite mit Fundstelle und Verlauf", angemeldet: true, }, + + // Phase 9 — Kassenverwaltung + { + name: "45-kasse", + pfad: "/kasse", + beschreibung: "Kassenübersicht mit den Beständen aller Konten", + angemeldet: true, + }, + { + name: "46-kassenbuch", + pfad: "/kasse/buchungen", + beschreibung: "Das Kassenbuch mit Summen über alle Treffer", + angemeldet: true, + }, + { + name: "47-beitraege", + pfad: "/kasse/beitraege", + beschreibung: "Mitgliedsbeiträge mit Rückstandsliste", + angemeldet: true, + }, + { + name: "48-rechnungen", + pfad: "/kasse/rechnungen", + beschreibung: "Rechnungen an Dritte mit Nummernkreis", + angemeldet: true, + }, + { + name: "49-kassenabschluss", + pfad: "/kasse/abschluss", + beschreibung: "Kassenprüfung mit Differenz und Erläuterung", + angemeldet: true, + }, ]; /** diff --git a/src/app/(app)/belege/beleg-liste.tsx b/src/app/(app)/belege/beleg-liste.tsx index d7dfe99..2ec360b 100644 --- a/src/app/(app)/belege/beleg-liste.tsx +++ b/src/app/(app)/belege/beleg-liste.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import Image from "next/image"; -import { Camera, Check, Trash2 } from "lucide-react"; +import { Camera, Check, Trash2, Wallet } from "lucide-react"; import { KiStand } from "@prisma/client"; import type { inferRouterOutputs } from "@trpc/server"; import { trpc } from "@/lib/trpc/client"; @@ -12,6 +12,21 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige"; import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung"; import { dateiHochladen } from "@/lib/hochladen"; @@ -33,9 +48,11 @@ import { alsDatum, alsEuro } from "@/lib/utils"; * Feld ist eine Aufforderung; ein geratener Wert wäre eine falsche Buchung, * die niemand bemerkt. */ -export function BelegListe() { +export function BelegListe({ darfBuchen }: { darfBuchen: boolean }) { const werkzeuge = trpc.useUtils(); + const [buchen, setBuchen] = React.useState(null); + const [laedtHoch, setLaedtHoch] = React.useState(false); const [meldung, setMeldung] = React.useState(null); const [hinweis, setHinweis] = React.useState(null); @@ -141,7 +158,8 @@ export function BelegListe() { Kategorie Betrag Stand - Gegengezeichnet + Gegengezeichnet + Kasse @@ -158,11 +176,23 @@ export function BelegListe() { {KI_STAND_TEXT[beleg.stand]} - + {beleg.bestaetigtVon ? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}` : "—"} + + {beleg.buchungId ? ( + gebucht + ) : beleg.stand === KiStand.UEBERNOMMEN && darfBuchen ? ( + + ) : ( + "—" + )} + ))} @@ -170,6 +200,19 @@ export function BelegListe() { )} + + setBuchen(null)} + fehler={setMeldung} + fertig={(betrag) => { + setBuchen(null); + setHinweis( + `Beleg über ${betrag} gebucht. Die Buchung steht als Entwurf im Kassenbuch.`, + ); + void werkzeuge.assistent.invalidate(); + }} + /> ); } @@ -327,3 +370,135 @@ function BelegKarte({ beleg, fehler }: { beleg: Beleg; fehler: (nachricht: strin ); } + +/** + * Aus einem gegengezeichneten Beleg eine Buchung machen. + * + * Konto und Kategorie fehlen dem Beleg — das kann keine Maschine entscheiden, + * und der Kassier soll es auch nicht raten müssen. Betrag, Datum und Händler + * stehen bereits fest; sie stammen aus dem gegengezeichneten Beleg und werden + * hier nicht noch einmal zur Bearbeitung gestellt. + * + * Das Foto hängt danach an der Buchung. Genau das verlangt eine + * Kassenprüfung: zu jeder Ausgabe ein Beleg, und zwar an der Buchung und + * nicht in einem Ordner daneben. + */ +function BelegBuchenDialog({ + beleg, + schliessen, + fertig, + fehler, +}: { + beleg: Beleg | null; + schliessen: () => void; + fertig: (betrag: string) => void; + fehler: (nachricht: string) => void; +}) { + const [kontoId, setKontoId] = React.useState(""); + const [kategorieId, setKategorieId] = React.useState(""); + + const konten = trpc.kassenkonto.liste.useQuery(undefined, { enabled: beleg !== null }); + const kategorien = trpc.buchungskategorie.liste.useQuery( + { art: "AUSGABE" }, + { enabled: beleg !== null }, + ); + + const vorschlag = trpc.buchung.vorschlag.useQuery( + { + verwendungszweck: `${beleg?.haendler ?? ""} ${beleg?.kategorie ?? ""}`.trim(), + art: "AUSGABE", + }, + { enabled: beleg !== null && Boolean(beleg.haendler ?? beleg.kategorie) }, + ); + + const buchen = trpc.buchung.belegBuchen.useMutation({ + onSuccess: (buchung) => fertig(alsEuro(Number(buchung.betrag))), + onError: (aufruf) => fehler(aufruf.message), + }); + + React.useEffect(() => { + if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id); + }, [kontoId, konten.data]); + + // Derselbe Vorschlag wie in der Buchungsmaske — sonst landet derselbe + // Händler einmal unter Kraftstoff und einmal unter Fahrzeuge, und die + // Jahresauswertung ist dahin. + React.useEffect(() => { + if (!kategorieId && vorschlag.data) setKategorieId(vorschlag.data.kategorieId); + }, [kategorieId, vorschlag.data]); + + React.useEffect(() => { + if (!beleg) setKategorieId(""); + }, [beleg]); + + return ( + !zustand && schliessen()}> + + + Beleg buchen + + {beleg?.haendler ?? "Beleg"} + {beleg?.betrag !== null && + beleg?.betrag !== undefined && + ` · ${alsEuro(Number(beleg.betrag))}`} + {beleg?.belegdatum && ` · ${alsDatum(beleg.belegdatum)}`}. Es entsteht eine Ausgabe im + Kassenbuch, mit dem Foto als Beleg. Sie ist zunächst ein Entwurf. + + + +
+
+ + +
+ +
+ + + {vorschlag.data && ( +

+ Vorgeschlagen: {vorschlag.data.name} — {vorschlag.data.grund} +

+ )} +
+
+ + + + + +
+
+ ); +} diff --git a/src/app/(app)/belege/page.tsx b/src/app/(app)/belege/page.tsx index 6eae4b1..30b1a5f 100644 --- a/src/app/(app)/belege/page.tsx +++ b/src/app/(app)/belege/page.tsx @@ -8,9 +8,9 @@ export const metadata = { title: "Belege" }; /** * Belegerfassung. * - * Quittung fotografieren, Angaben prüfen, gegenzeichnen. Die Buchung daraus - * kommt mit der Kasse in Phase 9 — hier entsteht erst einmal ein sauber - * erfasster Beleg mit Foto. + * Quittung fotografieren, Angaben prüfen, gegenzeichnen — und seit Phase 9 + * buchen. Der letzte Schritt braucht ein eigenes Recht: wer Belege erfasst, + * führt deshalb noch lange nicht die Kasse. */ export default async function BelegeSeite() { const sitzung = await aktuelleSitzung(); @@ -22,11 +22,11 @@ export default async function BelegeSeite() {

Belege

- Quittung fotografieren, Angaben prüfen, gegenzeichnen. + Quittung fotografieren, Angaben prüfen, gegenzeichnen — und ins Kassenbuch buchen.

- + ); } diff --git a/src/app/(app)/kasse/abschluss/ansicht.tsx b/src/app/(app)/kasse/abschluss/ansicht.tsx new file mode 100644 index 0000000..ddf6a9c --- /dev/null +++ b/src/app/(app)/kasse/abschluss/ansicht.tsx @@ -0,0 +1,780 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { ArrowLeft, Download, FileText, Lock, Plus } from "lucide-react"; +import { BuchungsArt } from "@prisma/client"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige"; +import { alsBetrag } from "@/lib/kasse/texte"; +import { alsDatum } from "@/lib/utils"; + +const MONATE = [ + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember", +]; + +/** + * Abschluss und Auswertung. + * + * Zwei Dinge, die zusammengehören: der Kassenprüfer will die Jahreszahlen + * sehen, bevor er unterschreibt, und die Jahreshauptversammlung will beides. + */ +export function Abschlussansicht({ darfAbschliessen }: { darfAbschliessen: boolean }) { + const [reiter, setReiter] = React.useState<"pruefung" | "auswertung">("pruefung"); + + return ( +
+ + +
+ setReiter("pruefung")}> + Kassenprüfung + + setReiter("auswertung")}> + Auswertung + +
+ + {reiter === "pruefung" ? ( + + ) : ( + + )} +
+ ); +} + +function Reiter({ + aktiv, + beim, + children, +}: { + aktiv: boolean; + beim: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +/** Die Liste der Prüfungen — und der Weg zu einer neuen. */ +function Pruefungen({ darfAbschliessen }: { darfAbschliessen: boolean }) { + const werkzeuge = trpc.useUtils(); + + const [anlegenOffen, setAnlegenOffen] = React.useState(false); + const [meldung, setMeldung] = React.useState(null); + const [hinweis, setHinweis] = React.useState(null); + + const abschluesse = trpc.kassenabschluss.liste.useQuery(); + + const festschreiben = trpc.kassenabschluss.festschreiben.useMutation({ + onSuccess: () => { + setHinweis( + "Festgeschrieben. Der Zeitraum ist zu, und der Prüfbericht entsteht im Hintergrund.", + ); + void werkzeuge.kassenabschluss.invalidate(); + void werkzeuge.buchung.invalidate(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + return ( +
+ {meldung && } + {hinweis && ( +
+ {hinweis} +
+ )} + + {darfAbschliessen && ( +
+ +
+ )} + + {!abschluesse.isLoading && (abschluesse.data?.length ?? 0) === 0 && ( + + )} + +
+ {abschluesse.data?.map((abschluss) => { + const differenz = Number(abschluss.differenz); + + return ( + + +
+
+ + {alsDatum(abschluss.vonAm)} bis {alsDatum(abschluss.bisAm)} + + + {abschluss.pruefer + ? `Geprüft von ${abschluss.pruefer.vorname} ${abschluss.pruefer.nachname}` + : "Noch kein Prüfer eingetragen"} + {abschluss.zweiterPruefer && + ` und ${abschluss.zweiterPruefer.vorname} ${abschluss.zweiterPruefer.nachname}`} + {abschluss.geprueftAm && ` am ${alsDatum(abschluss.geprueftAm)}`} + {` · ${abschluss._count.posten} Konten`} + +
+ + {abschluss.festgeschriebenAm ? ( + + + Festgeschrieben + + ) : ( + Entwurf + )} +
+
+ + +
+ + + 0.005} + /> +
+ + {abschluss.erlaeuterung && ( +

+ {abschluss.erlaeuterung} +

+ )} + +
+ {abschluss.berichtDatei && ( + + )} + + {darfAbschliessen && !abschluss.festgeschriebenAm && ( + + )} +
+ + {darfAbschliessen && !abschluss.festgeschriebenAm && ( +

+ Festschreiben schließt den Zeitraum: die Buchungen darin lassen sich danach + nicht mehr ändern, und ein neues Datum in diesem Zeitraum wird abgelehnt. +

+ )} +
+
+ ); + })} +
+ + {darfAbschliessen && ( + setAnlegenOffen(false)} + fehler={setMeldung} + fertig={() => { + setAnlegenOffen(false); + setHinweis( + "Angelegt — noch als Entwurf. Die Prüfer können ihre Zahlen korrigieren, solange sie beisammensitzen.", + ); + void werkzeuge.kassenabschluss.invalidate(); + }} + /> + )} +
+ ); +} + +function Wert({ + titel, + wert, + warnen = false, +}: { + titel: string; + wert: string | { toString(): string }; + warnen?: boolean; +}) { + return ( +
+

{titel}

+

+ {alsBetrag(wert)} +

+
+ ); +} + +/** + * Die Prüfungsmaske. + * + * Links steht, was das Buch sagt; rechts wird eingetragen, was gezählt wurde. + * In dieser Reihenfolge — wer erst das Buch sieht und dann zählt, zählt, bis + * es passt. + * + * Das ist der Grund, warum die Buchbestände hier zwar zu sehen sind (der + * Prüfer braucht sie für die Differenz), die gezählten Werte aber leer + * anfangen und nicht vorbelegt sind. + */ +function AbschlussAnlegen({ + offen, + schliessen, + fertig, + fehler, +}: { + offen: boolean; + schliessen: () => void; + fertig: () => void; + fehler: (nachricht: string) => void; +}) { + const jahr = new Date().getFullYear() - 1; + + const [vonAm, setVonAm] = React.useState(`${jahr}-01-01`); + const [bisAm, setBisAm] = React.useState(`${jahr}-12-31`); + const [prueferId, setPrueferId] = React.useState(""); + const [zweiterId, setZweiterId] = React.useState(""); + const [geprueftAm, setGeprueftAm] = React.useState(new Date().toISOString().slice(0, 10)); + const [erlaeuterung, setErlaeuterung] = React.useState(""); + const [gezaehlt, setGezaehlt] = React.useState>({}); + + const vorbereitung = trpc.kassenabschluss.vorbereiten.useQuery( + { vonAm: new Date(vonAm), bisAm: new Date(bisAm) }, + { enabled: offen && Boolean(vonAm) && Boolean(bisAm) }, + ); + + const mitglieder = trpc.person.liste.useQuery( + { seite: 1, seitengroesse: 200, richtung: "asc" }, + { enabled: offen }, + ); + + const anlegen = trpc.kassenabschluss.anlegen.useMutation({ + onSuccess: () => { + setGezaehlt({}); + setErlaeuterung(""); + fertig(); + }, + onError: (aufruf) => fehler(aufruf.message), + }); + + const posten = vorbereitung.data?.posten ?? []; + const alleGezaehlt = posten.every((eintrag) => gezaehlt[eintrag.kontoId]?.trim()); + + const summeGezaehlt = posten.reduce( + (gesamt, eintrag) => gesamt + Number(gezaehlt[eintrag.kontoId] || 0), + 0, + ); + const differenz = summeGezaehlt - Number(vorbereitung.data?.gesamt ?? 0); + const abweichung = Math.abs(differenz) > 0.005; + + return ( + !zustand && schliessen()}> + + + Kassenprüfung + + Erst zählen, dann eintragen. Der Buchbestand steht daneben, damit die Differenz + sichtbar wird — und nicht, damit die gezählte Zahl daran angepasst wird. + + + +
+
+
+ + setVonAm(ereignis.target.value)} + /> +
+ +
+ + setBisAm(ereignis.target.value)} + /> +
+
+ + {(vorbereitung.data?.offeneBuchungen ?? 0) > 0 && ( +
+ {vorbereitung.data?.offeneBuchungen} Buchungen im Zeitraum sind noch nicht + festgeschrieben. Das Festschreiben des Abschlusses nimmt sie mit — wer sie vorher + durchsieht, prüft einen Stand, der sich danach nicht mehr ändert. +
+ )} + + {posten.length > 0 && ( +
+ + + + + + + + + + {posten.map((eintrag) => { + const wert = gezaehlt[eintrag.kontoId] ?? ""; + const einzelDifferenz = wert + ? Number(wert) - Number(eintrag.bestandLautBuch) + : 0; + + return ( + + + + + + ); + })} + + + + + + + + +
KontoLaut BuchGezählt
+ {eintrag.bezeichnung} + + im Zeitraum +{alsBetrag(eintrag.einnahmen)} / − + {alsBetrag(eintrag.ausgaben)} + + + {alsBetrag(eintrag.bestandLautBuch)} + + + setGezaehlt((vorher) => ({ + ...vorher, + [eintrag.kontoId]: ereignis.target.value, + })) + } + aria-label={`Gezählter Bestand ${eintrag.bezeichnung}`} + /> + {wert && Math.abs(einzelDifferenz) > 0.005 && ( + + {einzelDifferenz > 0 ? "+" : ""} + {alsBetrag(einzelDifferenz.toFixed(2))} + + )} +
Summe + {alsBetrag(vorbereitung.data?.gesamt)} + + {alsBetrag(summeGezaehlt.toFixed(2))} +
+
+ )} + + {abweichung && ( +
+ Differenz von {alsBetrag(differenz.toFixed(2))}. Sie muss erklärt + werden, sonst lässt sich der Abschluss nicht festschreiben — in fünf Jahren weiß + sonst niemand mehr, woher sie kam. +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + setGeprueftAm(ereignis.target.value)} + /> +
+
+ +
+ +