Phase 9: Kassenverwaltung (#14)

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

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

After

Width:  |  Height:  |  Size: 727 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "florian",
"version": "0.9.0",
"version": "0.10.0",
"private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": {
@@ -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;
@@ -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();
+460
View File
@@ -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")
}
+653
View File
@@ -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<void> {
);
}
// -----------------------------------------------------------------------------
// 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<void> {
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<string, string>();
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<void> {
// Phase 8 — Lokale KI
await wikiAnlegen(organisation.id);
// Phase 9 — Kassenverwaltung
await kassendatenAnlegen(organisation.id);
log("Fertig.");
log("");
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
+32
View File
@@ -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,
},
];
/**
+179 -4
View File
@@ -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<Beleg | null>(null);
const [laedtHoch, setLaedtHoch] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
@@ -141,7 +158,8 @@ export function BelegListe() {
<th className="py-2 pr-4">Kategorie</th>
<th className="py-2 pr-4 text-right">Betrag</th>
<th className="py-2 pr-4">Stand</th>
<th className="py-2">Gegengezeichnet</th>
<th className="py-2 pr-4">Gegengezeichnet</th>
<th className="py-2">Kasse</th>
</tr>
</thead>
<tbody>
@@ -158,11 +176,23 @@ export function BelegListe() {
{KI_STAND_TEXT[beleg.stand]}
</Badge>
</td>
<td className="py-2">
<td className="py-2 pr-4">
{beleg.bestaetigtVon
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
: "—"}
</td>
<td className="py-2">
{beleg.buchungId ? (
<Badge variant="einsatzbereit">gebucht</Badge>
) : beleg.stand === KiStand.UEBERNOMMEN && darfBuchen ? (
<Button size="sm" variant="outline" onClick={() => setBuchen(beleg)}>
<Wallet aria-hidden />
Buchen
</Button>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
@@ -170,6 +200,19 @@ export function BelegListe() {
</div>
</section>
)}
<BelegBuchenDialog
beleg={buchen}
schliessen={() => setBuchen(null)}
fehler={setMeldung}
fertig={(betrag) => {
setBuchen(null);
setHinweis(
`Beleg über ${betrag} gebucht. Die Buchung steht als Entwurf im Kassenbuch.`,
);
void werkzeuge.assistent.invalidate();
}}
/>
</div>
);
}
@@ -327,3 +370,135 @@ function BelegKarte({ beleg, fehler }: { beleg: Beleg; fehler: (nachricht: strin
</Card>
);
}
/**
* 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 (
<Dialog open={beleg !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beleg buchen</DialogTitle>
<DialogDescription>
{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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="beleg-konto">Von welchem Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="beleg-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="beleg-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="beleg-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.oberkategorie
? `${kategorie.oberkategorie.name} › ${kategorie.name}`
: kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
{vorschlag.data && (
<p className="text-xs text-[var(--muted-foreground)]">
Vorgeschlagen: {vorschlag.data.name} — {vorschlag.data.grund}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => beleg && buchen.mutate({ belegId: beleg.id, kontoId, kategorieId })}
disabled={!kontoId || !kategorieId || buchen.isPending}
>
{buchen.isPending ? "Wird gebucht …" : "Buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+5 -5
View File
@@ -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() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Quittung fotografieren, Angaben prüfen, gegenzeichnen.
Quittung fotografieren, Angaben prüfen, gegenzeichnen — und ins Kassenbuch buchen.
</p>
</div>
<BelegListe />
<BelegListe darfBuchen={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
+780
View File
@@ -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 (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
<div className="flex gap-2 border-b border-[var(--border)]">
<Reiter aktiv={reiter === "pruefung"} beim={() => setReiter("pruefung")}>
Kassenprüfung
</Reiter>
<Reiter aktiv={reiter === "auswertung"} beim={() => setReiter("auswertung")}>
Auswertung
</Reiter>
</div>
{reiter === "pruefung" ? (
<Pruefungen darfAbschliessen={darfAbschliessen} />
) : (
<Auswertung />
)}
</div>
);
}
function Reiter({
aktiv,
beim,
children,
}: {
aktiv: boolean;
beim: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={beim}
className={`-mb-px border-b-2 px-3 py-2 text-sm ${
aktiv
? "border-[var(--primary)] font-medium"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{children}
</button>
);
}
/** 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<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(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 (
<div className="space-y-4">
{meldung && <Fehlermeldung meldung={meldung} kennung="abschlussfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{darfAbschliessen && (
<div className="flex justify-end">
<Button onClick={() => setAnlegenOffen(true)}>
<Plus aria-hidden />
Kassenprüfung anlegen
</Button>
</div>
)}
{!abschluesse.isLoading && (abschluesse.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Kassenprüfung"
hinweis="Ein Abschluss hält fest, was gezählt wurde und was das Buch sagt — und schließt den Zeitraum danach."
/>
)}
<div className="space-y-3">
{abschluesse.data?.map((abschluss) => {
const differenz = Number(abschluss.differenz);
return (
<Card key={abschluss.id}>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle className="text-base">
{alsDatum(abschluss.vonAm)} bis {alsDatum(abschluss.bisAm)}
</CardTitle>
<CardDescription>
{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`}
</CardDescription>
</div>
{abschluss.festgeschriebenAm ? (
<Badge variant="einsatzbereit">
<Lock className="size-3" aria-hidden />
Festgeschrieben
</Badge>
) : (
<Badge variant="outline">Entwurf</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 text-sm sm:grid-cols-3">
<Wert titel="Laut Buch" wert={abschluss.bestandLautBuch} />
<Wert titel="Gezählt" wert={abschluss.gezaehlterBestand} />
<Wert
titel="Differenz"
wert={abschluss.differenz}
warnen={Math.abs(differenz) > 0.005}
/>
</div>
{abschluss.erlaeuterung && (
<p className="rounded-md bg-[var(--muted)] p-3 text-sm">
{abschluss.erlaeuterung}
</p>
)}
<div className="flex flex-wrap gap-2">
{abschluss.berichtDatei && (
<Button size="sm" variant="outline" asChild>
<a href={`/api/dateien/${abschluss.berichtDatei.id}`} target="_blank">
<Download aria-hidden />
Prüfbericht
</a>
</Button>
)}
{darfAbschliessen && !abschluss.festgeschriebenAm && (
<Button
size="sm"
onClick={() => festschreiben.mutate({ id: abschluss.id })}
disabled={festschreiben.isPending}
>
<Lock aria-hidden />
Festschreiben
</Button>
)}
</div>
{darfAbschliessen && !abschluss.festgeschriebenAm && (
<p className="text-xs text-[var(--muted-foreground)]">
Festschreiben schließt den Zeitraum: die Buchungen darin lassen sich danach
nicht mehr ändern, und ein neues Datum in diesem Zeitraum wird abgelehnt.
</p>
)}
</CardContent>
</Card>
);
})}
</div>
{darfAbschliessen && (
<AbschlussAnlegen
offen={anlegenOffen}
schliessen={() => 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();
}}
/>
)}
</div>
);
}
function Wert({
titel,
wert,
warnen = false,
}: {
titel: string;
wert: string | { toString(): string };
warnen?: boolean;
}) {
return (
<div>
<p className="text-xs text-[var(--muted-foreground)]">{titel}</p>
<p
className={`text-lg font-semibold tabular-nums ${
warnen ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(wert)}
</p>
</div>
);
}
/**
* 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<Record<string, string>>({});
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 (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Kassenprüfung</DialogTitle>
<DialogDescription>
Erst zählen, dann eintragen. Der Buchbestand steht daneben, damit die Differenz
sichtbar wird — und nicht, damit die gezählte Zahl daran angepasst wird.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="abschluss-von">Zeitraum von</Label>
<Input
id="abschluss-von"
type="date"
value={vonAm}
onChange={(ereignis) => setVonAm(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-bis">bis</Label>
<Input
id="abschluss-bis"
type="date"
value={bisAm}
onChange={(ereignis) => setBisAm(ereignis.target.value)}
/>
</div>
</div>
{(vorbereitung.data?.offeneBuchungen ?? 0) > 0 && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
{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.
</div>
)}
{posten.length > 0 && (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Laut Buch</th>
<th className="px-3 py-2 text-right">Gezählt</th>
</tr>
</thead>
<tbody>
{posten.map((eintrag) => {
const wert = gezaehlt[eintrag.kontoId] ?? "";
const einzelDifferenz = wert
? Number(wert) - Number(eintrag.bestandLautBuch)
: 0;
return (
<tr
key={eintrag.kontoId}
className="border-b border-[var(--border)] last:border-0"
>
<td className="px-3 py-2">
{eintrag.bezeichnung}
<span className="block text-xs text-[var(--muted-foreground)]">
im Zeitraum +{alsBetrag(eintrag.einnahmen)} / −
{alsBetrag(eintrag.ausgaben)}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(eintrag.bestandLautBuch)}
</td>
<td className="px-3 py-2 text-right">
<Input
type="number"
step="0.01"
className="w-32 text-right"
value={wert}
onChange={(ereignis) =>
setGezaehlt((vorher) => ({
...vorher,
[eintrag.kontoId]: ereignis.target.value,
}))
}
aria-label={`Gezählter Bestand ${eintrag.bezeichnung}`}
/>
{wert && Math.abs(einzelDifferenz) > 0.005 && (
<span className="block text-xs text-[var(--destructive)]">
{einzelDifferenz > 0 ? "+" : ""}
{alsBetrag(einzelDifferenz.toFixed(2))}
</span>
)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-[var(--border)] font-medium">
<td className="px-3 py-2">Summe</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(vorbereitung.data?.gesamt)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(summeGezaehlt.toFixed(2))}
</td>
</tr>
</tfoot>
</table>
</div>
)}
{abweichung && (
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
Differenz von <strong>{alsBetrag(differenz.toFixed(2))}</strong>. Sie muss erklärt
werden, sonst lässt sich der Abschluss nicht festschreiben — in fünf Jahren weiß
sonst niemand mehr, woher sie kam.
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="abschluss-pruefer">Kassenprüfer</Label>
<Select value={prueferId} onValueChange={setPrueferId}>
<SelectTrigger id="abschluss-pruefer">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{mitglieder.data?.eintraege.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-zweiter">Zweiter Prüfer</Label>
<Select value={zweiterId} onValueChange={setZweiterId}>
<SelectTrigger id="abschluss-zweiter">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{mitglieder.data?.eintraege.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-geprueft">Geprüft am</Label>
<Input
id="abschluss-geprueft"
type="date"
value={geprueftAm}
onChange={(ereignis) => setGeprueftAm(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-erlaeuterung">
Erläuterung {abweichung && <span className="text-[var(--destructive)]">*</span>}
</Label>
<Textarea
id="abschluss-erlaeuterung"
rows={3}
value={erlaeuterung}
onChange={(ereignis) => setErlaeuterung(ereignis.target.value)}
placeholder="Bar 3,20 € weniger — Wechselgeld vom Maifest, Beleg fehlt."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
vonAm: new Date(vonAm),
bisAm: new Date(bisAm),
prueferId: prueferId || null,
zweiterPrueferId: zweiterId || null,
geprueftAm: geprueftAm ? new Date(geprueftAm) : null,
erlaeuterung: erlaeuterung.trim() || null,
posten: posten.map((eintrag) => ({
kontoId: eintrag.kontoId,
gezaehlterBestand: Number(gezaehlt[eintrag.kontoId] ?? 0),
})),
})
}
disabled={posten.length === 0 || !alleGezaehlt || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Als Entwurf anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Jahreszahlen für die Jahreshauptversammlung. */
function Auswertung() {
const [jahr, setJahr] = React.useState(new Date().getFullYear());
const [exportLaeuft, setExportLaeuft] = React.useState(false);
const werkzeuge = trpc.useUtils();
const auswertung = trpc.kassenabschluss.auswertung.useQuery({ jahr });
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
async function herunterladen() {
setExportLaeuft(true);
try {
const datei = await werkzeuge.kassenabschluss.export.fetch({ jahr });
// Der Umweg über einen Blob statt eines Links auf eine API-Route: die
// Daten liegen bereits vor, und so entsteht kein zweiter Weg an der
// Rechteprüfung vorbei.
const ziel = URL.createObjectURL(
new Blob([datei.inhalt], { type: "text/csv;charset=utf-8" }),
);
const verweis = document.createElement("a");
verweis.href = ziel;
verweis.download = datei.dateiname;
verweis.click();
URL.revokeObjectURL(ziel);
} finally {
setExportLaeuft(false);
}
}
const einnahmen = auswertung.data?.kategorien.filter(
(eintrag) => eintrag.art === BuchungsArt.EINNAHME,
);
const ausgaben = auswertung.data?.kategorien.filter(
(eintrag) => eintrag.art === BuchungsArt.AUSGABE,
);
const hoechster = Math.max(
1,
...(auswertung.data?.monate.flatMap((monat) => [
Number(monat.einnahmen),
Number(monat.ausgaben),
]) ?? [1]),
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
className="ml-auto"
onClick={() => void herunterladen()}
disabled={exportLaeuft}
>
<FileText aria-hidden />
{exportLaeuft ? "Wird erzeugt …" : "Kassenbuch als CSV"}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Vergleich
titel="Einnahmen"
wert={auswertung.data?.gesamt.einnahmen}
vorjahr={auswertung.data?.vorjahr.einnahmen}
jahr={jahr}
/>
<Vergleich
titel="Ausgaben"
wert={auswertung.data?.gesamt.ausgaben}
vorjahr={auswertung.data?.vorjahr.ausgaben}
jahr={jahr}
/>
<Vergleich
titel="Saldo"
wert={auswertung.data?.gesamt.saldo}
vorjahr={auswertung.data?.vorjahr.saldo}
jahr={jahr}
/>
</div>
{auswertung.data?.anzahl === 0 && (
<NichtsGefunden
titel={`Für ${jahr} steht nichts im Kassenbuch`}
hinweis="Ohne Buchungen gibt es nichts auszuwerten."
/>
)}
{(auswertung.data?.anzahl ?? 0) > 0 && (
<>
{/* Der Jahresverlauf. Zwölf Balkenpaare, kein Diagrammpaket —
das reicht, um zu sehen, wo das Fest war. */}
<Card>
<CardHeader>
<CardTitle className="text-base">Verlauf {jahr}</CardTitle>
<CardDescription>
Einnahmen und Ausgaben je Monat, gemessen am größten Monatswert.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{auswertung.data?.monate.map((monat) => (
<div key={monat.monat} className="flex items-center gap-3 text-xs">
<span className="w-20 shrink-0 text-[var(--muted-foreground)]">
{MONATE[monat.monat - 1]}
</span>
<div className="flex flex-1 flex-col gap-0.5">
<div
className="h-2 rounded-sm bg-[var(--einsatzbereit)]"
style={{ width: `${(Number(monat.einnahmen) / hoechster) * 100}%` }}
/>
<div
className="h-2 rounded-sm bg-[var(--destructive)]"
style={{ width: `${(Number(monat.ausgaben) / hoechster) * 100}%` }}
/>
</div>
<span className="w-28 shrink-0 text-right tabular-nums">
{alsBetrag(monat.saldo)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Kategorieliste titel="Einnahmen nach Kategorie" zeilen={einnahmen} />
<Kategorieliste titel="Ausgaben nach Kategorie" zeilen={ausgaben} />
</div>
</>
)}
</div>
);
}
function Vergleich({
titel,
wert,
vorjahr,
jahr,
}: {
titel: string;
wert: string | undefined;
vorjahr: string | undefined;
jahr: number;
}) {
const jetzt = Number(wert ?? 0);
const davor = Number(vorjahr ?? 0);
const unterschied = jetzt - davor;
return (
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
<p className="text-xs text-[var(--muted-foreground)]">
{jahr - 1}: {alsBetrag(vorjahr)}
{davor !== 0 && (
<>
{" · "}
{unterschied >= 0 ? "+" : "−"}
{alsBetrag(Math.abs(unterschied).toFixed(2))}
</>
)}
</p>
</CardContent>
</Card>
);
}
function Kategorieliste({
titel,
zeilen,
}: {
titel: string;
zeilen: Array<{ id: string; name: string; oberkategorie: string | null; summe: string }> | undefined;
}) {
const gesamt = zeilen?.reduce((wert, zeile) => wert + Number(zeile.summe), 0) ?? 0;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{titel}</CardTitle>
</CardHeader>
<CardContent>
{(zeilen?.length ?? 0) === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Nichts gebucht.</p>
) : (
<ul className="space-y-1.5 text-sm">
{zeilen?.map((zeile) => (
<li key={zeile.id}>
<div className="flex justify-between gap-2">
<span>
{zeile.name}
{zeile.oberkategorie && (
<span className="text-[var(--muted-foreground)]">
{" "}
· {zeile.oberkategorie}
</span>
)}
</span>
<span className="tabular-nums">{alsBetrag(zeile.summe)}</span>
</div>
<div
className="mt-0.5 h-1 rounded-sm bg-[var(--primary)]/40"
style={{ width: `${gesamt > 0 ? (Number(zeile.summe) / gesamt) * 100 : 0}%` }}
/>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Abschlussansicht } from "@/app/(app)/kasse/abschluss/ansicht";
export const metadata = { title: "Kassenabschluss" };
export default async function AbschlussSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Abschluss und Auswertung</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Kassenprüfung, Jahreszahlen und der Export für Steuerberater oder Kämmerei.
</p>
</div>
<Abschlussansicht darfAbschliessen={hatRecht(sitzung, "kasse.abschluss")} />
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Beitragsverwaltung } from "@/app/(app)/kasse/beitraege/verwaltung";
export const metadata = { title: "Beiträge" };
export default async function BeitraegeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mitgliedsbeiträge</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Sätze, Beitragslauf und wer noch nicht gezahlt hat.
</p>
</div>
<Beitragsverwaltung darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,735 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { AlertTriangle, ArrowLeft, Check, Mail, PlayCircle, Plus } from "lucide-react";
import { BeitragStand } 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 {
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 { BEITRAG_STAND_TEXT, BEITRAG_STAND_VARIANTE, alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Die Beitragsverwaltung.
*
* Der Ablauf, wie er im Januar wirklich stattfindet:
*
* 1. Sätze prüfen — sind sie noch aktuell?
* 2. Beitragslauf starten. Vorher steht da, wie viele Beiträge entstehen und
* wer keinen Satz hat.
* 3. Zahlungen abhaken. Jede erzeugt eine Buchung.
* 4. Im Herbst mahnen, was offen ist.
*
* Die Liste ohne Satz ist der interessante Teil der Vorschau: das sind die
* Leute, für die niemand entschieden hat, was sie zahlen.
*/
export function Beitragsverwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [jahr, setJahr] = React.useState(new Date().getFullYear());
const [stand, setStand] = React.useState<BeitragStand | "alle">("alle");
const [suche, setSuche] = React.useState("");
const [laufOffen, setLaufOffen] = React.useState(false);
const [satzOffen, setSatzOffen] = React.useState(false);
const [bezahlen, setBezahlen] = React.useState<{ id: string; name: string; betrag: string } | null>(
null,
);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const [gewaehlt, setGewaehlt] = React.useState<Set<string>>(new Set());
const saetze = trpc.beitrag.saetze.useQuery();
const beitraege = trpc.beitrag.liste.useQuery({
jahr,
stand: stand === "alle" ? undefined : stand,
suche: suche.trim() || undefined,
});
const rueckstaende = trpc.beitrag.rueckstaende.useQuery();
const mahnen = trpc.beitrag.mahnen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
`${ergebnis.gemahnt} Beiträge gemahnt.` +
(ergebnis.uebersprungen > 0
? ` ${ergebnis.uebersprungen} übersprungen — dort ist die fünfte Mahnstufe erreicht, und was danach kommt, entscheidet die Kommandantschaft.`
: ""),
);
setGewaehlt(new Set());
void werkzeuge.beitrag.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const erlassen = trpc.beitrag.erlassen.useMutation({
onSuccess: () => void werkzeuge.beitrag.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
const zeilen = beitraege.data?.beitraege ?? [];
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="beitragsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kennzahl titel="Sollstellung" wert={beitraege.data?.kennzahlen.gesamt} />
<Kennzahl titel="Bezahlt" wert={beitraege.data?.kennzahlen.bezahlt} />
<Kennzahl
titel="Offen"
wert={beitraege.data?.kennzahlen.offen}
hinweis={`${beitraege.data?.kennzahlen.anzahlOffen ?? 0} Mitglieder`}
hervorheben={(beitraege.data?.kennzahlen.anzahlOffen ?? 0) > 0}
/>
<Kennzahl titel="Erlassen" wert={beitraege.data?.kennzahlen.erlassen} />
</div>
{/* Rückstände über alle Jahre — die Liste für die Jahreshauptversammlung */}
{(rueckstaende.data?.zeilen.length ?? 0) > 0 && (
<Card className="border-[var(--pruefung-faellig)]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="size-4" aria-hidden />
Rückstände über alle Jahre
</CardTitle>
<CardDescription>
{rueckstaende.data?.anzahl} offene Beiträge über{" "}
{alsBetrag(rueckstaende.data?.gesamt)}. Wer über mehrere Jahre offen steht, fällt
hier auf — und sonst nirgends.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm">
{rueckstaende.data?.zeilen.slice(0, 10).map((zeile) => (
<li key={zeile.person.id} className="flex flex-wrap justify-between gap-2">
<span>
{zeile.person.nachname}, {zeile.person.vorname}
<span className="text-[var(--muted-foreground)]">
{" "}
· {zeile.jahre.join(", ")}
{zeile.hoechsteMahnstufe > 0 && ` · ${zeile.hoechsteMahnstufe}. Mahnung`}
</span>
</span>
<span className="tabular-nums font-medium">{alsBetrag(zeile.summe)}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Sätze */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Beitragssätze</h2>
{darfBearbeiten && (
<Button variant="outline" size="sm" onClick={() => setSatzOffen(true)}>
<Plus aria-hidden />
Satz anlegen
</Button>
)}
</div>
{(saetze.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Noch keine Sätze"
hinweis="Ohne Satz weiß FLORIAN nicht, was ein Mitglied zahlt — der Beitragslauf legt dann nichts an."
/>
) : (
<div className="flex flex-wrap gap-2">
{saetze.data?.map((satz) => (
<div
key={satz.id}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] px-3 py-2 text-sm"
>
<span className="font-medium">{satz.bezeichnung}</span>
<span className="text-[var(--muted-foreground)]">
{" "}
· {alsBetrag(satz.betrag)} ab {satz.abJahr}
{satz.abteilung ? ` · ${satz.abteilung}` : " · alle Abteilungen"}
</span>
</div>
))}
</div>
)}
</section>
{/* Liste */}
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stand} onValueChange={(wert) => setStand(wert as BeitragStand | "alle")}>
<SelectTrigger className="w-44" aria-label="Stand">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Stände</SelectItem>
{Object.values(BeitragStand).map((wert) => (
<SelectItem key={wert} value={wert}>
{BEITRAG_STAND_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Name oder Mitgliedsnummer"
className="min-w-48 flex-1"
aria-label="Mitglieder durchsuchen"
/>
{darfBearbeiten && (
<div className="ml-auto flex gap-2">
{gewaehlt.size > 0 && (
<Button
variant="outline"
onClick={() => mahnen.mutate({ ids: [...gewaehlt] })}
disabled={mahnen.isPending}
>
<Mail aria-hidden />
{gewaehlt.size} mahnen
</Button>
)}
<Button onClick={() => setLaufOffen(true)}>
<PlayCircle aria-hidden />
Beitragslauf {jahr}
</Button>
</div>
)}
</div>
{!beitraege.isLoading && zeilen.length === 0 && (
<NichtsGefunden
titel={`Keine Beiträge für ${jahr}`}
hinweis={
darfBearbeiten
? "Der Beitragslauf legt sie für alle aktiven Mitglieder an."
: "Für dieses Jahr wurde noch nicht abgerechnet."
}
/>
)}
{zeilen.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
{darfBearbeiten && <th className="px-3 py-2" />}
<th className="px-3 py-2">Mitglied</th>
<th className="px-3 py-2">Abteilung</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2">Stand</th>
<th className="px-3 py-2">Bezahlt</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{zeilen.map((beitrag) => {
const offen =
beitrag.stand === BeitragStand.OFFEN || beitrag.stand === BeitragStand.GEMAHNT;
return (
<tr key={beitrag.id} className="border-b border-[var(--border)] last:border-0">
{darfBearbeiten && (
<td className="px-3 py-2">
{offen && (
<input
type="checkbox"
checked={gewaehlt.has(beitrag.id)}
onChange={(ereignis) => {
const neu = new Set(gewaehlt);
if (ereignis.target.checked) neu.add(beitrag.id);
else neu.delete(beitrag.id);
setGewaehlt(neu);
}}
aria-label={`${beitrag.person.nachname} auswählen`}
/>
)}
</td>
)}
<td className="px-3 py-2">
{beitrag.person.nachname}, {beitrag.person.vorname}
<span className="block text-xs text-[var(--muted-foreground)]">
{beitrag.person.mitgliedsnummer}
</span>
</td>
<td className="px-3 py-2">{beitrag.person.abteilung ?? "—"}</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(beitrag.betrag)}
</td>
<td className="px-3 py-2">
<Badge variant={BEITRAG_STAND_VARIANTE[beitrag.stand]}>
{BEITRAG_STAND_TEXT[beitrag.stand]}
</Badge>
{beitrag.mahnstufe > 0 && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
{beitrag.mahnstufe}.
</span>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap">
{beitrag.bezahltAm ? alsDatum(beitrag.bezahltAm) : "—"}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{darfBearbeiten && offen && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setBezahlen({
id: beitrag.id,
name: `${beitrag.person.nachname}, ${beitrag.person.vorname}`,
betrag: String(beitrag.betrag),
})
}
>
<Check aria-hidden />
Bezahlt
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
const grund = window.prompt(
"Warum wird der Beitrag erlassen? Ohne Begründung ist er in der Kassenprüfung eine offene Frage.",
);
if (grund?.trim()) {
erlassen.mutate({ id: beitrag.id, grund: grund.trim() });
}
}}
>
Erlassen
</Button>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
{darfBearbeiten && (
<>
<BeitragslaufDialog
offen={laufOffen}
jahr={jahr}
schliessen={() => setLaufOffen(false)}
fehler={setMeldung}
fertig={(angelegt) => {
setLaufOffen(false);
setHinweis(`${angelegt} Beiträge für ${jahr} angelegt.`);
void werkzeuge.beitrag.invalidate();
}}
/>
<SatzDialog
offen={satzOffen}
schliessen={() => setSatzOffen(false)}
fehler={setMeldung}
fertig={() => {
setSatzOffen(false);
void werkzeuge.beitrag.invalidate();
}}
/>
<BezahlenDialog
eintrag={bezahlen}
schliessen={() => setBezahlen(null)}
fehler={setMeldung}
fertig={() => {
setBezahlen(null);
void werkzeuge.beitrag.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Kennzahl({
titel,
wert,
hinweis,
hervorheben = false,
}: {
titel: string;
wert: string | undefined;
hinweis?: string;
hervorheben?: boolean;
}) {
return (
<Card className={hervorheben ? "border-[var(--pruefung-faellig)]" : undefined}>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
{hinweis && <p className="text-xs text-[var(--muted-foreground)]">{hinweis}</p>}
</CardContent>
</Card>
);
}
/** Der Beitragslauf mit Vorschau. */
function BeitragslaufDialog({
offen,
jahr,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
jahr: number;
schliessen: () => void;
fertig: (angelegt: number) => void;
fehler: (nachricht: string) => void;
}) {
const vorschau = trpc.beitrag.laufVorschau.useQuery({ jahr }, { enabled: offen });
const lauf = trpc.beitrag.lauf.useMutation({
onSuccess: (ergebnis) => fertig(ergebnis.angelegt),
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Beitragslauf {jahr}</DialogTitle>
<DialogDescription>
Legt an, was fehlt. Wiederholt ausgeführt entsteht nichts doppelt — der Lauf lässt
sich also im Februar noch einmal starten, wenn Neue dazugekommen sind.
</DialogDescription>
</DialogHeader>
{vorschau.isLoading && <p className="text-sm">Wird gerechnet …</p>}
{vorschau.data && (
<div className="space-y-4 text-sm">
<div className="rounded-md bg-[var(--muted)] p-3">
<p>
<strong>{vorschau.data.anzulegen.length} Beiträge</strong> werden angelegt, Summe{" "}
<strong>{alsBetrag(vorschau.data.summe)}</strong>.
</p>
{vorschau.data.bereitsVorhanden > 0 && (
<p className="text-[var(--muted-foreground)]">
{vorschau.data.bereitsVorhanden} Mitglieder haben für {jahr} bereits einen
Beitrag — die bleiben unberührt.
</p>
)}
</div>
{vorschau.data.ohneSatz.length > 0 && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3">
<p className="font-medium">
{vorschau.data.ohneSatz.length} Mitglieder ohne passenden Satz
</p>
<p className="mt-1 text-[var(--muted-foreground)]">
Für sie entsteht kein Beitrag. Entweder fehlt ein Satz für ihre Abteilung, oder
es gibt keinen allgemeinen.
</p>
<ul className="mt-2 list-disc pl-5">
{vorschau.data.ohneSatz.slice(0, 8).map((eintrag) => (
<li key={eintrag.personId}>
{eintrag.name}
{eintrag.abteilung && ` (${eintrag.abteilung})`}
</li>
))}
</ul>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => lauf.mutate({ jahr })}
disabled={!vorschau.data?.anzulegen.length || lauf.isPending}
>
{lauf.isPending ? "Läuft …" : `${vorschau.data?.anzulegen.length ?? 0} anlegen`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function SatzDialog({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [betrag, setBetrag] = React.useState("");
const [abteilung, setAbteilung] = React.useState("");
const [abJahr, setAbJahr] = React.useState(String(new Date().getFullYear()));
const anlegen = trpc.beitrag.satzAnlegen.useMutation({
onSuccess: () => {
setBezeichnung("");
setBetrag("");
setAbteilung("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beitragssatz anlegen</DialogTitle>
<DialogDescription>
Ein Satz gilt ab einem Jahr und wird nicht rückwirkend geändert — sonst stimmen die
Beiträge vergangener Jahre nicht mehr, und die sind bereits geprüft.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="satz-bezeichnung">Bezeichnung</Label>
<Input
id="satz-bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Aktive Mitglieder"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="satz-betrag">Betrag je Jahr</Label>
<Input
id="satz-betrag"
type="number"
step="0.01"
min="0"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="satz-abteilung">Abteilung</Label>
<Input
id="satz-abteilung"
value={abteilung}
onChange={(ereignis) => setAbteilung(ereignis.target.value)}
placeholder="leer = alle"
/>
</div>
<div className="space-y-2">
<Label htmlFor="satz-jahr">Ab Jahr</Label>
<Input
id="satz-jahr"
type="number"
value={abJahr}
onChange={(ereignis) => setAbJahr(ereignis.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
bezeichnung,
betrag: Number(betrag),
abteilung: abteilung.trim() || null,
abJahr: Number(abJahr),
aktiv: true,
})
}
disabled={!bezeichnung.trim() || !betrag || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Zahlungseingang — erzeugt zugleich die Buchung. */
function BezahlenDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; name: string; betrag: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(new Date().toISOString().slice(0, 10));
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art: "EINNAHME" });
const bezahlen = trpc.beitrag.bezahlen.useMutation({
onSuccess: fertig,
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
// Die Kategorie „Mitgliedsbeiträge" vorbelegen — sie ist fast immer die
// richtige und kommt aus dem Standardkontenrahmen.
if (!kategorieId && kategorien.data) {
const treffer = kategorien.data.find((eintrag) =>
eintrag.name.toLowerCase().includes("beitr"),
);
if (treffer) setKategorieId(treffer.id);
}
}, [kontoId, kategorieId, konten.data, kategorien.data]);
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beitrag als bezahlt vermerken</DialogTitle>
<DialogDescription>
{eintrag?.name} · {alsBetrag(eintrag?.betrag)} — es entsteht zugleich die Buchung.
Ein Häkchen ohne Geld in der Kasse wäre eine Behauptung.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="bezahlt-datum">Bezahlt am</Label>
<Input
id="bezahlt-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bezahlt-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="bezahlt-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="bezahlt-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="bezahlt-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
eintrag &&
bezahlen.mutate({
id: eintrag.id,
kontoId,
kategorieId,
bezahltAm: new Date(datum),
})
}
disabled={!kontoId || !kategorieId || bezahlen.isPending}
>
{bezahlen.isPending ? "Wird gebucht …" : "Bezahlt und buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,929 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Lock, Plus, Search, Sparkles, Undo2, Upload } 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, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { BUCHUNGSART_TEXT, alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Das Kassenbuch.
*
* Oben die Summen über **alle** Treffer, nicht nur über die angezeigten —
* eine Summe unter einer auf hundert Zeilen begrenzten Liste bedeutet sonst
* nichts.
*
* Festgeschriebene Buchungen tragen ein Schloss. Sie lassen sich weder ändern
* noch löschen; wer sich vertan hat, bucht dagegen. Beide bleiben stehen, und
* im Kassenbuch ist zu sehen, dass etwas berichtigt wurde — genau das will ein
* Kassenprüfer sehen.
*/
export function BuchungsListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [suche, setSuche] = React.useState("");
const [kontoId, setKontoId] = React.useState<string | "alle">("alle");
const [art, setArt] = React.useState<BuchungsArt | "alle">("alle");
const [nurOffene, setNurOffene] = React.useState(false);
const [erfassenOffen, setErfassenOffen] = React.useState(false);
const [importOffen, setImportOffen] = React.useState(false);
const [gegenbuchen, setGegenbuchen] = React.useState<{ id: string; zweck: string } | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const konten = trpc.kassenkonto.liste.useQuery();
const buchungen = trpc.buchung.liste.useQuery({
suche: suche.trim() || undefined,
kontoId: kontoId === "alle" ? undefined : kontoId,
art: art === "alle" ? undefined : art,
nurOffene,
anzahl: 200,
});
const festschreiben = trpc.buchung.festschreiben.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
ergebnis.festgeschrieben === 0
? "Es war nichts festzuschreiben."
: `${ergebnis.festgeschrieben} Buchungen festgeschrieben. Sie sind ab jetzt unveränderbar.`,
);
void werkzeuge.buchung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const loeschen = trpc.buchung.loeschen.useMutation({
onSuccess: () => void werkzeuge.buchung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = buchungen.data?.buchungen ?? [];
const offene = eintraege.filter((eintrag) => !eintrag.festgeschriebenAm).length;
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="buchungsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{/* Summen über alle Treffer */}
<div className="grid gap-3 sm:grid-cols-3">
<Summe titel="Einnahmen" wert={buchungen.data?.summe.einnahmen} />
<Summe titel="Ausgaben" wert={buchungen.data?.summe.ausgaben} />
<Summe titel="Saldo" wert={buchungen.data?.summe.saldo} hervorheben />
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Verwendungszweck, Lieferant, Belegnummer …"
className="pl-9"
aria-label="Buchungen durchsuchen"
/>
</div>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger className="w-52" aria-label="Konto">
<SelectValue placeholder="Alle Konten" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Konten</SelectItem>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={art} onValueChange={(wert) => setArt(wert as BuchungsArt | "alle")}>
<SelectTrigger className="w-40" aria-label="Art">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle</SelectItem>
{Object.values(BuchungsArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{BUCHUNGSART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={nurOffene ? "default" : "outline"}
size="sm"
onClick={() => setNurOffene((wert) => !wert)}
>
Nur Entwürfe
</Button>
{darfBearbeiten && (
<div className="ml-auto flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setImportOffen(true)}>
<Upload aria-hidden />
Kontoauszug
</Button>
<Button onClick={() => setErfassenOffen(true)}>
<Plus aria-hidden />
Buchung erfassen
</Button>
</div>
)}
</div>
{darfBearbeiten && offene > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
<span className="flex-1">
{offene} {offene === 1 ? "Buchung ist" : "Buchungen sind"} noch nicht festgeschrieben.
Festgeschriebene lassen sich nicht mehr ändern — das ist der Zustand, den eine
Kassenprüfung erwartet.
</span>
<Button
size="sm"
variant="outline"
onClick={() => festschreiben.mutate({ bisAm: new Date() })}
disabled={festschreiben.isPending}
>
<Lock aria-hidden />
Alles bis heute festschreiben
</Button>
</div>
)}
{!buchungen.isLoading && eintraege.length === 0 && (
<NichtsGefunden
titel="Keine Buchungen"
hinweis={
suche || kontoId !== "alle" || art !== "alle" || nurOffene
? "Zu dieser Auswahl steht nichts im Kassenbuch."
: "Die erste Buchung entsteht über den Knopf oben rechts."
}
/>
)}
{eintraege.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Verwendungszweck</th>
<th className="px-3 py-2">Kategorie</th>
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{eintraege.map((buchung) => (
<tr key={buchung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap">{alsDatum(buchung.datum)}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span>{buchung.verwendungszweck}</span>
{buchung.festgeschriebenAm && (
<Lock
className="size-3.5 text-[var(--muted-foreground)]"
aria-label="festgeschrieben"
/>
)}
{buchung.storniertVonId && (
<Badge variant="secondary">gegengebucht</Badge>
)}
</div>
{(buchung.lieferant || buchung.person) && (
<span className="text-xs text-[var(--muted-foreground)]">
{buchung.person
? `${buchung.person.nachname}, ${buchung.person.vorname}`
: buchung.lieferant}
</span>
)}
</td>
<td className="px-3 py-2">{buchung.kategorie.name}</td>
<td className="px-3 py-2">{buchung.konto.bezeichnung}</td>
<td
className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${
buchung.art === BuchungsArt.AUSGABE ? "text-[var(--destructive)]" : ""
}`}
>
{buchung.art === BuchungsArt.AUSGABE ? "−" : "+"}
{alsBetrag(buchung.betrag)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{darfBearbeiten && !buchung.storniertVonId && (
<>
{buchung.festgeschriebenAm ? (
<Button
size="sm"
variant="ghost"
onClick={() =>
setGegenbuchen({
id: buchung.id,
zweck: buchung.verwendungszweck,
})
}
>
<Undo2 aria-hidden />
Gegenbuchen
</Button>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => loeschen.mutate({ id: buchung.id })}
>
Verwerfen
</Button>
)}
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{darfBearbeiten && (
<>
<BuchungErfassen
offen={erfassenOffen}
schliessen={() => setErfassenOffen(false)}
fehler={setMeldung}
fertig={() => {
setErfassenOffen(false);
void werkzeuge.buchung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
<Kontoauszug
offen={importOffen}
schliessen={() => setImportOffen(false)}
fehler={setMeldung}
fertig={(anzahl) => {
setImportOffen(false);
setHinweis(`${anzahl} Buchungen als Entwurf übernommen. Bitte durchsehen.`);
void werkzeuge.buchung.invalidate();
}}
/>
<GegenbuchenDialog
eintrag={gegenbuchen}
schliessen={() => setGegenbuchen(null)}
fehler={setMeldung}
fertig={() => {
setGegenbuchen(null);
void werkzeuge.buchung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Summe({
titel,
wert,
hervorheben = false,
}: {
titel: string;
wert: string | undefined;
hervorheben?: boolean;
}) {
const zahl = Number(wert ?? 0);
return (
<Card className={hervorheben ? "border-[var(--primary)]" : undefined}>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p
className={`text-xl font-semibold tabular-nums ${
hervorheben && zahl < 0 ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(wert)}
</p>
</CardContent>
</Card>
);
}
/** Datum von heute im Format, das ein date-Feld erwartet. */
function heute(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Die Buchungsmaske.
*
* Der Kategorievorschlag ist die eine Bequemlichkeit, die hier wirklich etwas
* bringt: nicht wegen der gesparten Sekunden, sondern weil dieselbe Sache
* dadurch immer unter derselben Kategorie landet — und erst das macht die
* Jahresauswertung brauchbar.
*/
function BuchungErfassen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [art, setArt] = React.useState<BuchungsArt>(BuchungsArt.AUSGABE);
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(heute());
const [betrag, setBetrag] = React.useState("");
const [zweck, setZweck] = React.useState("");
const [lieferant, setLieferant] = React.useState("");
const [belegnummer, setBelegnummer] = React.useState("");
const [vorschlagUebernommen, setVorschlagUebernommen] = React.useState(false);
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art });
const vorschlag = trpc.buchung.vorschlag.useQuery(
{ verwendungszweck: zweck, art },
{ enabled: offen && zweck.trim().length >= 3 },
);
const anlegen = trpc.buchung.anlegen.useMutation({
onSuccess: () => {
setBetrag("");
setZweck("");
setLieferant("");
setBelegnummer("");
setVorschlagUebernommen(false);
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
// Das erste Konto vorbelegen — die meisten Wehren buchen fast alles auf
// dasselbe.
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
// Beim Wechsel der Richtung passt die gewählte Kategorie nicht mehr.
React.useEffect(() => {
setKategorieId("");
setVorschlagUebernommen(false);
}, [art]);
const passenderVorschlag =
vorschlag.data && !kategorieId && !vorschlagUebernommen ? vorschlag.data : null;
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Buchung erfassen</DialogTitle>
<DialogDescription>
Der Betrag ist immer positiv — die Richtung steht daneben. Die Buchung ist zunächst
ein Entwurf und lässt sich noch ändern.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex gap-2">
{Object.values(BuchungsArt).map((wert) => (
<Button
key={wert}
type="button"
variant={art === wert ? "default" : "outline"}
className="flex-1"
onClick={() => setArt(wert)}
>
{BUCHUNGSART_TEXT[wert]}
</Button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="buchung-datum">Buchungsdatum</Label>
<Input
id="buchung-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-betrag">Betrag</Label>
<Input
id="buchung-betrag"
type="number"
step="0.01"
min="0.01"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
placeholder="0,00"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-zweck">Verwendungszweck</Label>
<Textarea
id="buchung-zweck"
rows={2}
value={zweck}
onChange={(ereignis) => setZweck(ereignis.target.value)}
placeholder="Tankstelle Musterhausen, Diesel LF 20"
/>
</div>
{passenderVorschlag && (
<button
type="button"
onClick={() => {
setKategorieId(passenderVorschlag.kategorieId);
setVorschlagUebernommen(true);
}}
className="flex w-full items-center gap-2 rounded-md border border-[var(--primary)] bg-[var(--primary)]/5 p-3 text-left text-sm hover:bg-[var(--primary)]/10"
>
<Sparkles className="size-4 shrink-0" aria-hidden />
<span>
Vorschlag: <strong>{passenderVorschlag.name}</strong>
<span className="block text-xs text-[var(--muted-foreground)]">
{passenderVorschlag.grund}
</span>
</span>
</button>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="buchung-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="buchung-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.oberkategorie
? `${kategorie.oberkategorie.name} › ${kategorie.name}`
: kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-konto">Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="buchung-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-lieferant">
{art === BuchungsArt.AUSGABE ? "Empfänger" : "Einzahler"}
</Label>
<Input
id="buchung-lieferant"
value={lieferant}
onChange={(ereignis) => setLieferant(ereignis.target.value)}
placeholder="Werkstatt Müller GmbH"
/>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-beleg">Belegnummer</Label>
<Input
id="buchung-beleg"
value={belegnummer}
onChange={(ereignis) => setBelegnummer(ereignis.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
kontoId,
kategorieId,
datum: new Date(datum),
art,
betrag: Number(betrag),
verwendungszweck: zweck,
lieferant: lieferant || null,
belegnummer: belegnummer || null,
})
}
disabled={
!kontoId || !kategorieId || !zweck.trim() || !(Number(betrag) > 0) || anlegen.isPending
}
>
{anlegen.isPending ? "Wird gebucht …" : "Buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Kontoauszug einlesen.
*
* Der eigentliche Wert ist die Spalte „schon da": ein zweimal eingelesener
* Auszug wäre sonst ein doppelt gebuchter Monat, und das fällt erst beim
* Kassenabschluss auf.
*/
function Kontoauszug({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (anzahl: number) => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [gelesen, setGelesen] = React.useState<{
format: string;
konto: string | null;
fehler: Array<{ stelle: string; grund: string }>;
posten: Array<{
datum: Date;
art: BuchungsArt;
betrag: string;
verwendungszweck: string;
gegenseite: string | null;
schonDa: boolean;
}>;
bekannt: number;
} | null>(null);
const [gewaehlt, setGewaehlt] = React.useState<Set<number>>(new Set());
const [kategorieId, setKategorieId] = React.useState("");
const dateiRef = React.useRef<HTMLInputElement>(null);
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery();
const lesen = trpc.buchung.auszugLesen.useMutation({
onSuccess: (ergebnis) => {
setGelesen(ergebnis);
// Vorausgewählt ist, was noch nicht da ist — das ist fast immer das,
// was jemand übernehmen will.
setGewaehlt(
new Set(
ergebnis.posten
.map((posten, stelle) => (posten.schonDa ? -1 : stelle))
.filter((stelle) => stelle >= 0),
),
);
},
onError: (aufruf) => fehler(aufruf.message),
});
const uebernehmen = trpc.buchung.auszugUebernehmen.useMutation({
onSuccess: (ergebnis) => {
setGelesen(null);
setGewaehlt(new Set());
fertig(ergebnis.uebernommen);
},
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
async function dateiWaehlen(datei: File) {
const inhalt = await datei.text();
lesen.mutate({ kontoId, inhalt });
}
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Kontoauszug einlesen</DialogTitle>
<DialogDescription>
CAMT.053 (XML) oder MT940 — beides bietet das Online-Banking unter „Umsatzexport“ an.
Es entstehen <strong>Entwürfe</strong>, keine fertigen Buchungen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-52 flex-1 space-y-2">
<Label htmlFor="auszug-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="auszug-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<input
ref={dateiRef}
type="file"
accept=".xml,.camt,.txt,.sta,.mt940,text/xml,text/plain"
className="hidden"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (datei) void dateiWaehlen(datei);
ereignis.target.value = "";
}}
/>
<Button
onClick={() => dateiRef.current?.click()}
disabled={!kontoId || lesen.isPending}
>
<Upload aria-hidden />
{lesen.isPending ? "Wird gelesen …" : "Datei wählen"}
</Button>
</div>
{gelesen && (
<>
<p className="text-sm">
Format <strong>{gelesen.format}</strong>
{gelesen.konto && ` · Konto ${gelesen.konto}`} · {gelesen.posten.length} Umsätze
{gelesen.bekannt > 0 && ` · ${gelesen.bekannt} davon stehen schon in der Kasse`}
</p>
{gelesen.fehler.length > 0 && (
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
<p className="font-medium">Nicht gelesen werden konnte:</p>
<ul className="mt-1 list-disc pl-5">
{gelesen.fehler.slice(0, 8).map((eintrag, stelle) => (
<li key={stelle}>
{eintrag.stelle}: {eintrag.grund}
</li>
))}
</ul>
</div>
)}
{gelesen.posten.length > 0 && (
<>
<div className="space-y-2">
<Label htmlFor="auszug-kategorie">Kategorie für alle übernommenen</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="auszug-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{BUCHUNGSART_TEXT[kategorie.art]} · {kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-[var(--muted-foreground)]">
Sie lässt sich danach je Buchung ändern — die Entwürfe sind dafür da.
</p>
</div>
<div className="max-h-72 overflow-y-auto rounded-md border border-[var(--border)]">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--card)]">
<tr className="border-b border-[var(--border)] text-left">
<th className="px-2 py-1.5" />
<th className="px-2 py-1.5">Datum</th>
<th className="px-2 py-1.5">Verwendungszweck</th>
<th className="px-2 py-1.5 text-right">Betrag</th>
</tr>
</thead>
<tbody>
{gelesen.posten.map((posten, stelle) => (
<tr
key={stelle}
className={`border-b border-[var(--border)] last:border-0 ${
posten.schonDa ? "opacity-50" : ""
}`}
>
<td className="px-2 py-1.5">
<input
type="checkbox"
checked={gewaehlt.has(stelle)}
onChange={(ereignis) => {
const neu = new Set(gewaehlt);
if (ereignis.target.checked) neu.add(stelle);
else neu.delete(stelle);
setGewaehlt(neu);
}}
aria-label={`Umsatz ${stelle + 1} übernehmen`}
/>
</td>
<td className="px-2 py-1.5 whitespace-nowrap">
{alsDatum(posten.datum)}
</td>
<td className="px-2 py-1.5">
{posten.verwendungszweck.slice(0, 80)}
{posten.gegenseite && (
<span className="block text-xs text-[var(--muted-foreground)]">
{posten.gegenseite}
</span>
)}
{posten.schonDa && (
<Badge variant="secondary" className="mt-1">
steht schon in der Kasse
</Badge>
)}
</td>
<td
className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${
posten.art === BuchungsArt.AUSGABE
? "text-[var(--destructive)]"
: ""
}`}
>
{posten.art === BuchungsArt.AUSGABE ? "−" : "+"}
{alsBetrag(posten.betrag)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
uebernehmen.mutate({
kontoId,
posten: [...gewaehlt].map((stelle) => {
const posten = gelesen!.posten[stelle]!;
return {
datum: posten.datum,
art: posten.art,
betrag: Number(posten.betrag),
verwendungszweck: posten.verwendungszweck.slice(0, 300),
gegenseite: posten.gegenseite,
kategorieId,
};
}),
})
}
disabled={gewaehlt.size === 0 || !kategorieId || uebernehmen.isPending}
>
{uebernehmen.isPending
? "Wird übernommen …"
: `${gewaehlt.size} übernehmen`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Gegenbuchen — mit Grund, denn er steht später im Verwendungszweck. */
function GegenbuchenDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; zweck: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [grund, setGrund] = React.useState("");
const gegenbuchen = trpc.buchung.gegenbuchen.useMutation({
onSuccess: () => {
setGrund("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Gegenbuchen</DialogTitle>
<DialogDescription>
Es entsteht eine zweite Buchung mit derselben Summe in die andere Richtung, mit
Verweis auf die erste. Beide bleiben stehen — genau das will ein Kassenprüfer sehen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md bg-[var(--muted)] p-3 text-sm">{eintrag?.zweck}</div>
<div className="space-y-2">
<Label htmlFor="gegenbuchung-grund">Warum?</Label>
<Input
id="gegenbuchung-grund"
value={grund}
onChange={(ereignis) => setGrund(ereignis.target.value)}
placeholder="Betrag falsch erfasst"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Der Grund steht im Verwendungszweck der Gegenbuchung. Das Buchungsdatum ist heute —
ein geprüfter Zeitraum bleibt zu.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => eintrag && gegenbuchen.mutate({ id: eintrag.id, grund })}
disabled={grund.trim().length < 3 || gegenbuchen.isPending}
>
{gegenbuchen.isPending ? "Wird gebucht …" : "Gegenbuchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { BuchungsListe } from "@/app/(app)/kasse/buchungen/buchungs-liste";
export const metadata = { title: "Buchungen" };
export default async function BuchungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Buchungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Das Kassenbuch. Festgeschriebene Buchungen sind unveränderbar — Korrekturen laufen über
eine Gegenbuchung.
</p>
</div>
<BuchungsListe darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { KassenUebersicht } from "@/app/(app)/kasse/uebersicht";
export const metadata = { title: "Kasse" };
/**
* Die Kasse der Wehr.
*
* Oben die Konten mit ihrem Bestand — die Zahl, die der Kassier zuerst sehen
* will. Darunter, was zu tun ist: Entwürfe, offene Beiträge, überfällige
* Rechnungen.
*/
export default async function KasseSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Kasse</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Konten, Buchungen, Beiträge und der Kassenabschluss.
</p>
</div>
<KassenUebersicht
darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")}
darfAbschluss={hatRecht(sitzung, "kasse.abschluss")}
/>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Rechnungsverwaltung } from "@/app/(app)/kasse/rechnungen/verwaltung";
export const metadata = { title: "Rechnungen" };
export default async function RechnungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Rechnungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Kostenersatz für Fehlalarme und Öleinsätze, Vermietungen, Veranstaltungen.
</p>
</div>
<Rechnungsverwaltung darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,824 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Check, Download, FileText, Mail, Plus, Send, Trash2, X } from "lucide-react";
import { RechnungsAnlass, RechnungStand } 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, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import {
ANLASS_TEXT,
RECHNUNG_STAND_TEXT,
RECHNUNG_STAND_VARIANTE,
alsBetrag,
} from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
type Position = {
bezeichnung: string;
menge: string;
einheit: string;
einzelpreis: string;
};
const LEERE_POSITION: Position = { bezeichnung: "", menge: "1", einheit: "", einzelpreis: "" };
/**
* Rechnungen.
*
* Eine Rechnung ist bis zum Versenden ein Entwurf und danach nicht mehr
* änderbar. Der Grund ist derselbe wie beim Kassenbuch: was beim Empfänger
* liegt, muss dem entsprechen, was hier steht. Korrigiert wird über Storno
* und eine neue Rechnung — mit eigener Nummer, damit keine Lücke entsteht.
*/
export function Rechnungsverwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [stand, setStand] = React.useState<RechnungStand | "alle">("alle");
const [jahr, setJahr] = React.useState<number | "alle">(new Date().getFullYear());
const [suche, setSuche] = React.useState("");
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [bezahlen, setBezahlen] = React.useState<{
id: string;
nummer: string;
betrag: string;
} | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const rechnungen = trpc.rechnung.liste.useQuery({
stand: stand === "alle" ? undefined : stand,
jahr: jahr === "alle" ? undefined : jahr,
suche: suche.trim() || undefined,
});
const versenden = trpc.rechnung.versenden.useMutation({
onSuccess: () => {
setHinweis("Versendet. Das PDF entsteht im Hintergrund und steht gleich zum Herunterladen.");
void werkzeuge.rechnung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const mahnen = trpc.rechnung.mahnen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
`${ergebnis.gemahnt} Rechnungen gemahnt.` +
(ergebnis.uebersprungen > 0
? ` ${ergebnis.uebersprungen} übersprungen — dort steht die dritte Mahnung, danach ist es eine Frage für die Kommandantschaft.`
: ""),
);
void werkzeuge.rechnung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const stornieren = trpc.rechnung.stornieren.useMutation({
onSuccess: () => void werkzeuge.rechnung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const pdfErzeugen = trpc.rechnung.pdfErzeugen.useMutation({
onSuccess: (ergebnis) => setHinweis(ergebnis.hinweis),
onError: (fehler) => setMeldung(fehler.message),
});
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
const zeilen = rechnungen.data?.rechnungen ?? [];
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="rechnungsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<Kennzahl titel="Gestellt" wert={rechnungen.data?.kennzahlen.gesamt} />
<Kennzahl
titel="Offen"
wert={rechnungen.data?.kennzahlen.offen}
hinweis={`${rechnungen.data?.kennzahlen.anzahlOffen ?? 0} Rechnungen`}
/>
<Card
className={
(rechnungen.data?.kennzahlen.ueberfaellig ?? 0) > 0
? "border-[var(--gesperrt)]"
: undefined
}
>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
Überfällig
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">
{rechnungen.data?.kennzahlen.ueberfaellig ?? 0}
</p>
<p className="text-xs text-[var(--muted-foreground)]">Zahlungsziel verstrichen</p>
</CardContent>
</Card>
</div>
<div className="flex flex-wrap items-center gap-3">
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Empfänger oder Rechnungsnummer"
className="min-w-52 flex-1"
aria-label="Rechnungen durchsuchen"
/>
<Select
value={String(jahr)}
onValueChange={(wert) => setJahr(wert === "alle" ? "alle" : Number(wert))}
>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Jahre</SelectItem>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stand} onValueChange={(wert) => setStand(wert as RechnungStand | "alle")}>
<SelectTrigger className="w-44" aria-label="Stand">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Stände</SelectItem>
{Object.values(RechnungStand).map((wert) => (
<SelectItem key={wert} value={wert}>
{RECHNUNG_STAND_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
{darfBearbeiten && (
<Button className="ml-auto" onClick={() => setAnlegenOffen(true)}>
<Plus aria-hidden />
Rechnung schreiben
</Button>
)}
</div>
{!rechnungen.isLoading && zeilen.length === 0 && (
<NichtsGefunden
titel="Keine Rechnungen"
hinweis="Kostenersatz nach Art. 28 BayFwG wird hier geschrieben — Fehlalarme, Öleinsätze, Vermietungen."
/>
)}
{zeilen.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Nummer</th>
<th className="px-3 py-2">Empfänger</th>
<th className="px-3 py-2">Anlass</th>
<th className="px-3 py-2">Fällig</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2">Stand</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{zeilen.map((rechnung) => {
const offen =
rechnung.stand === RechnungStand.VERSENDET ||
rechnung.stand === RechnungStand.GEMAHNT;
const ueberfaellig = offen && new Date(rechnung.faelligAm) < new Date();
return (
<tr key={rechnung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap font-medium">{rechnung.nummer}</td>
<td className="px-3 py-2">
{rechnung.empfaenger}
{rechnung.ort && (
<span className="block text-xs text-[var(--muted-foreground)]">
{rechnung.plz} {rechnung.ort}
</span>
)}
</td>
<td className="px-3 py-2">{ANLASS_TEXT[rechnung.anlass]}</td>
<td
className={`px-3 py-2 whitespace-nowrap ${
ueberfaellig ? "text-[var(--destructive)]" : ""
}`}
>
{alsDatum(rechnung.faelligAm)}
</td>
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
{alsBetrag(rechnung.betrag)}
</td>
<td className="px-3 py-2">
<Badge variant={RECHNUNG_STAND_VARIANTE[rechnung.stand]}>
{RECHNUNG_STAND_TEXT[rechnung.stand]}
</Badge>
{rechnung.mahnstufe > 0 && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
{rechnung.mahnstufe}. Mahnung
</span>
)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{rechnung.pdfDateiId && (
<Button size="sm" variant="ghost" asChild>
<a href={`/api/dateien/${rechnung.pdfDateiId}`} target="_blank">
<Download aria-hidden />
<span className="sr-only">PDF herunterladen</span>
</a>
</Button>
)}
{darfBearbeiten && rechnung.stand === RechnungStand.ENTWURF && (
<Button
size="sm"
variant="ghost"
onClick={() => versenden.mutate({ id: rechnung.id })}
disabled={versenden.isPending}
>
<Send aria-hidden />
Versenden
</Button>
)}
{darfBearbeiten && offen && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setBezahlen({
id: rechnung.id,
nummer: rechnung.nummer,
betrag: String(rechnung.betrag),
})
}
>
<Check aria-hidden />
Bezahlt
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => mahnen.mutate({ ids: [rechnung.id] })}
disabled={mahnen.isPending}
>
<Mail aria-hidden />
Mahnen
</Button>
</>
)}
{darfBearbeiten && rechnung.stand !== RechnungStand.STORNIERT && (
<>
{rechnung.stand !== RechnungStand.ENTWURF && !rechnung.pdfDateiId && (
<Button
size="sm"
variant="ghost"
onClick={() => pdfErzeugen.mutate({ id: rechnung.id })}
>
<FileText aria-hidden />
<span className="sr-only">PDF erzeugen</span>
</Button>
)}
{rechnung.stand !== RechnungStand.BEZAHLT && (
<Button
size="sm"
variant="ghost"
onClick={() => {
const grund = window.prompt(
"Warum wird storniert? Der Grund steht später in der Bemerkung — die Rechnungsnummer bleibt vergeben.",
);
if (grund?.trim()) {
stornieren.mutate({ id: rechnung.id, grund: grund.trim() });
}
}}
>
<X aria-hidden />
<span className="sr-only">Stornieren</span>
</Button>
)}
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{darfBearbeiten && (
<>
<RechnungAnlegen
offen={anlegenOffen}
schliessen={() => setAnlegenOffen(false)}
fehler={setMeldung}
fertig={(nummer) => {
setAnlegenOffen(false);
setHinweis(`Rechnung ${nummer} als Entwurf angelegt. Sie ist noch änderbar.`);
void werkzeuge.rechnung.invalidate();
}}
/>
<BezahltDialog
eintrag={bezahlen}
schliessen={() => setBezahlen(null)}
fehler={setMeldung}
fertig={() => {
setBezahlen(null);
void werkzeuge.rechnung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Kennzahl({
titel,
wert,
hinweis,
}: {
titel: string;
wert: string | undefined;
hinweis?: string;
}) {
return (
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
{hinweis && <p className="text-xs text-[var(--muted-foreground)]">{hinweis}</p>}
</CardContent>
</Card>
);
}
/**
* Die Rechnungsmaske.
*
* Der Gesamtbetrag wird nicht eingegeben, sondern aus den Positionen
* gerechnet — hier zur Anzeige, auf dem Server noch einmal verbindlich. Ein
* eingetippter Betrag, der nicht zur Summe der Positionen passt, ist der
* Fehler, den niemand bemerkt, bis der Empfänger anruft.
*/
function RechnungAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (nummer: string) => void;
fehler: (nachricht: string) => void;
}) {
const heute = new Date();
const inVierWochen = new Date(heute.getTime() + 28 * 24 * 60 * 60 * 1000);
const [anlass, setAnlass] = React.useState<RechnungsAnlass>(RechnungsAnlass.FEHLALARM);
const [empfaenger, setEmpfaenger] = React.useState("");
const [zusatz, setZusatz] = React.useState("");
const [strasse, setStrasse] = React.useState("");
const [plz, setPlz] = React.useState("");
const [ort, setOrt] = React.useState("");
const [datum, setDatum] = React.useState(heute.toISOString().slice(0, 10));
const [faellig, setFaellig] = React.useState(inVierWochen.toISOString().slice(0, 10));
const [bemerkung, setBemerkung] = React.useState("");
const [positionen, setPositionen] = React.useState<Position[]>([{ ...LEERE_POSITION }]);
const anlegen = trpc.rechnung.anlegen.useMutation({
onSuccess: (rechnung) => {
setEmpfaenger("");
setZusatz("");
setStrasse("");
setPlz("");
setOrt("");
setBemerkung("");
setPositionen([{ ...LEERE_POSITION }]);
fertig(rechnung.nummer);
},
onError: (aufruf) => fehler(aufruf.message),
});
function aendern(stelle: number, feld: keyof Position, wert: string) {
setPositionen((vorher) =>
vorher.map((position, index) => (index === stelle ? { ...position, [feld]: wert } : position)),
);
}
const summe = positionen.reduce(
(gesamt, position) => gesamt + Number(position.menge || 0) * Number(position.einzelpreis || 0),
0,
);
const vollstaendig =
empfaenger.trim().length > 0 &&
positionen.every(
(position) => position.bezeichnung.trim().length > 0 && Number(position.einzelpreis) >= 0,
);
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Rechnung schreiben</DialogTitle>
<DialogDescription>
Die Rechnungsnummer vergibt FLORIAN fortlaufend je Jahr — lückenlos, weil eine Lücke
in der Kassenprüfung erklärt werden muss.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="rechnung-anlass">Anlass</Label>
<Select value={anlass} onValueChange={(wert) => setAnlass(wert as RechnungsAnlass)}>
<SelectTrigger id="rechnung-anlass">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(RechnungsAnlass).map((wert) => (
<SelectItem key={wert} value={wert}>
{ANLASS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-empfaenger">Empfänger</Label>
<Input
id="rechnung-empfaenger"
value={empfaenger}
onChange={(ereignis) => setEmpfaenger(ereignis.target.value)}
placeholder="Firma Muster GmbH"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="rechnung-zusatz">Zusatz</Label>
<Input
id="rechnung-zusatz"
value={zusatz}
onChange={(ereignis) => setZusatz(ereignis.target.value)}
placeholder="z. Hd. Herrn Meier"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="rechnung-strasse">Straße</Label>
<Input
id="rechnung-strasse"
value={strasse}
onChange={(ereignis) => setStrasse(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-plz">PLZ</Label>
<Input
id="rechnung-plz"
value={plz}
onChange={(ereignis) => setPlz(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-ort">Ort</Label>
<Input
id="rechnung-ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-datum">Rechnungsdatum</Label>
<Input
id="rechnung-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-faellig">Zahlbar bis</Label>
<Input
id="rechnung-faellig"
type="date"
value={faellig}
onChange={(ereignis) => setFaellig(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Positionen</Label>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setPositionen((vorher) => [...vorher, { ...LEERE_POSITION }])}
>
<Plus aria-hidden />
Position
</Button>
</div>
<div className="space-y-2">
{positionen.map((position, stelle) => (
<div key={stelle} className="flex flex-wrap items-end gap-2">
<div className="min-w-40 flex-1 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-bezeichnung`}>
Bezeichnung
</Label>
<Input
id={`position-${stelle}-bezeichnung`}
value={position.bezeichnung}
onChange={(ereignis) => aendern(stelle, "bezeichnung", ereignis.target.value)}
placeholder="Einsatz LF 20, angefangene Stunde"
/>
</div>
<div className="w-20 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-menge`}>
Menge
</Label>
<Input
id={`position-${stelle}-menge`}
type="number"
step="0.01"
min="0.01"
value={position.menge}
onChange={(ereignis) => aendern(stelle, "menge", ereignis.target.value)}
/>
</div>
<div className="w-20 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-einheit`}>
Einheit
</Label>
<Input
id={`position-${stelle}-einheit`}
value={position.einheit}
onChange={(ereignis) => aendern(stelle, "einheit", ereignis.target.value)}
placeholder="Std."
/>
</div>
<div className="w-28 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-preis`}>
Einzelpreis
</Label>
<Input
id={`position-${stelle}-preis`}
type="number"
step="0.01"
min="0"
value={position.einzelpreis}
onChange={(ereignis) => aendern(stelle, "einzelpreis", ereignis.target.value)}
/>
</div>
<Button
type="button"
size="icon"
variant="ghost"
disabled={positionen.length === 1}
onClick={() =>
setPositionen((vorher) => vorher.filter((_, index) => index !== stelle))
}
>
<Trash2 aria-hidden />
<span className="sr-only">Position entfernen</span>
</Button>
</div>
))}
</div>
<p className="text-right text-sm">
Summe <strong className="tabular-nums">{alsBetrag(summe.toFixed(2))}</strong>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bemerkung">Bemerkung</Label>
<Textarea
id="rechnung-bemerkung"
rows={2}
value={bemerkung}
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
placeholder="Einsatz vom 14.03. — Fehlalarm durch Brandmeldeanlage"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
anlass,
empfaenger,
empfaengerZusatz: zusatz || null,
strasse: strasse || null,
plz: plz || null,
ort: ort || null,
datum: new Date(datum),
faelligAm: new Date(faellig),
bemerkung: bemerkung || null,
positionen: positionen.map((position) => ({
bezeichnung: position.bezeichnung,
menge: Number(position.menge || 1),
einheit: position.einheit || null,
einzelpreis: Number(position.einzelpreis || 0),
})),
})
}
disabled={!vollstaendig || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Als Entwurf anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Zahlungseingang zu einer Rechnung — erzeugt die Buchung. */
function BezahltDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; nummer: string; betrag: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(new Date().toISOString().slice(0, 10));
const [teilbetrag, setTeilbetrag] = React.useState("");
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art: "EINNAHME" });
const bezahlen = trpc.rechnung.bezahlen.useMutation({
onSuccess: () => {
setTeilbetrag("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rechnung {eintrag?.nummer} bezahlt</DialogTitle>
<DialogDescription>
Über {alsBetrag(eintrag?.betrag)}. Es entsteht zugleich die Buchung im Kassenbuch.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-datum">Eingegangen am</Label>
<Input
id="rechnung-bezahlt-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-betrag">Abweichender Betrag</Label>
<Input
id="rechnung-bezahlt-betrag"
type="number"
step="0.01"
min="0.01"
value={teilbetrag}
onChange={(ereignis) => setTeilbetrag(ereignis.target.value)}
placeholder={eintrag?.betrag}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="rechnung-bezahlt-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="rechnung-bezahlt-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
eintrag &&
bezahlen.mutate({
id: eintrag.id,
kontoId,
kategorieId,
bezahltAm: new Date(datum),
betrag: teilbetrag ? Number(teilbetrag) : undefined,
})
}
disabled={!kontoId || !kategorieId || bezahlen.isPending}
>
{bezahlen.isPending ? "Wird gebucht …" : "Bezahlt und buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+457
View File
@@ -0,0 +1,457 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
BanknoteArrowUp,
ClipboardCheck,
FileText,
Landmark,
Plus,
Users,
Wallet,
} from "lucide-react";
import { KontoArt } 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 {
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 { KONTOART_TEXT, alsBetrag, ibanKurz } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Die Kassenübersicht.
*
* Zuerst die Konten mit ihrem Bestand — die Zahl, wegen der jemand hierher
* kommt. Der Bestand ist **gerechnet**, nicht gespeichert: Anfangsbestand plus
* die Wirkung aller Buchungen. Ein gespeicherter Bestand neben einer
* nachgetragenen Buchung wäre falsch, ohne falsch auszusehen.
*
* Darunter die Arbeit, die ansteht. Nicht als Zahlenwand, sondern als vier
* Wege in die vier Bereiche — wer die Kasse führt, hat drei bis fünf Dinge zu
* tun, und die sollen mit einem Klick erreichbar sein.
*/
export function KassenUebersicht({
darfBearbeiten,
darfAbschluss,
}: {
darfBearbeiten: boolean;
darfAbschluss: boolean;
}) {
const werkzeuge = trpc.useUtils();
const jahr = new Date().getFullYear();
const [kontoOffen, setKontoOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const konten = trpc.kassenkonto.liste.useQuery({ mitInaktiven: false });
const kategorien = trpc.buchungskategorie.liste.useQuery();
const offeneBuchungen = trpc.buchung.liste.useQuery({ nurOffene: true, anzahl: 1 });
const beitraege = trpc.beitrag.liste.useQuery({ jahr });
const rechnungen = trpc.rechnung.liste.useQuery({ jahr });
const letzte = trpc.buchung.liste.useQuery({ anzahl: 8 });
const standard = trpc.buchungskategorie.standardAnlegen.useMutation({
onSuccess: () => void werkzeuge.buchungskategorie.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const gesamt = (konten.data ?? []).reduce(
(bisher, konto) => bisher + Number(konto.bestand),
0,
);
const keineKategorien = (kategorien.data?.length ?? 0) === 0;
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="kassenfehler" />}
{/* Der Einstieg für eine Wehr, die gerade anfängt. */}
{keineKategorien && darfBearbeiten && !kategorien.isLoading && (
<Card className="border-[var(--pruefung-faellig)]">
<CardHeader>
<CardTitle className="text-base">Die Kasse ist noch leer</CardTitle>
<CardDescription>
Es fehlen die Kategorien, unter denen gebucht wird. FLORIAN bringt einen
Kontenrahmen mit, wie ihn eine Feuerwehr braucht — er lässt sich danach
erweitern und umbenennen.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => standard.mutate()} disabled={standard.isPending}>
{standard.isPending ? "Wird angelegt …" : "Kontenrahmen anlegen"}
</Button>
</CardContent>
</Card>
)}
{/* Konten */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Konten</h2>
{darfBearbeiten && (
<Button variant="outline" size="sm" onClick={() => setKontoOffen(true)}>
<Plus aria-hidden />
Konto anlegen
</Button>
)}
</div>
{konten.isLoading && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 3 }).map((_, zeile) => (
<div
key={zeile}
className="h-28 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
aria-hidden
/>
))}
</div>
)}
{!konten.isLoading && (konten.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Konten"
hinweis="Eine Wehr führt meistens drei: die Barkasse im Gerätehaus, ein Bankkonto und eine Rücklage."
/>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{konten.data?.map((konto) => (
<Card key={konto.id}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{konto.art === KontoArt.BAR ? (
<Wallet className="size-4 text-[var(--muted-foreground)]" aria-hidden />
) : (
<Landmark className="size-4 text-[var(--muted-foreground)]" aria-hidden />
)}
<CardTitle className="text-sm font-medium">{konto.bezeichnung}</CardTitle>
</div>
<CardDescription>
{KONTOART_TEXT[konto.art]}
{konto.iban && ` · ${ibanKurz(konto.iban)}`}
</CardDescription>
</CardHeader>
<CardContent>
<p
className={`text-2xl font-semibold tabular-nums ${
Number(konto.bestand) < 0 ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(konto.bestand)}
</p>
</CardContent>
</Card>
))}
{(konten.data?.length ?? 0) > 1 && (
<Card className="border-[var(--primary)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Gesamt</CardTitle>
<CardDescription>über alle Konten</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">
{alsBetrag(gesamt.toFixed(2))}
</p>
</CardContent>
</Card>
)}
</div>
</section>
{/* Was ansteht */}
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Aufgabe
titel="Buchungen"
symbol={<BanknoteArrowUp className="size-4" aria-hidden />}
wert={offeneBuchungen.data?.buchungen.length ? "Entwürfe offen" : "alles erfasst"}
hinweis={`Saldo ${alsBetrag(letzte.data?.summe.saldo)}`}
pfad="/kasse/buchungen"
hervorheben={Boolean(offeneBuchungen.data?.buchungen.length)}
/>
<Aufgabe
titel={`Beiträge ${jahr}`}
symbol={<Users className="size-4" aria-hidden />}
wert={
beitraege.data
? `${beitraege.data.kennzahlen.anzahlOffen} offen`
: "—"
}
hinweis={beitraege.data ? `${alsBetrag(beitraege.data.kennzahlen.offen)} ausstehend` : ""}
pfad="/kasse/beitraege"
hervorheben={(beitraege.data?.kennzahlen.anzahlOffen ?? 0) > 0}
/>
<Aufgabe
titel="Rechnungen"
symbol={<FileText className="size-4" aria-hidden />}
wert={rechnungen.data ? `${rechnungen.data.kennzahlen.anzahlOffen} offen` : "—"}
hinweis={
rechnungen.data?.kennzahlen.ueberfaellig
? `${rechnungen.data.kennzahlen.ueberfaellig} überfällig`
: rechnungen.data
? `${alsBetrag(rechnungen.data.kennzahlen.offen)} ausstehend`
: ""
}
pfad="/kasse/rechnungen"
hervorheben={(rechnungen.data?.kennzahlen.ueberfaellig ?? 0) > 0}
/>
<Aufgabe
titel="Abschluss"
symbol={<ClipboardCheck className="size-4" aria-hidden />}
wert={darfAbschluss ? "Kassenprüfung" : "nur ansehen"}
hinweis="Bestände zählen und festschreiben"
pfad="/kasse/abschluss"
/>
</section>
{/* Die letzten Buchungen */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Zuletzt gebucht</h2>
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse/buchungen">Alle Buchungen</Link>
</Button>
</div>
{(letzte.data?.buchungen.length ?? 0) === 0 && !letzte.isLoading ? (
<NichtsGefunden
titel="Noch nichts gebucht"
hinweis="Die erste Buchung entsteht unter „Buchungen“."
/>
) : (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Verwendungszweck</th>
<th className="px-3 py-2">Kategorie</th>
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Betrag</th>
</tr>
</thead>
<tbody>
{letzte.data?.buchungen.map((buchung) => (
<tr key={buchung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap">{alsDatum(buchung.datum)}</td>
<td className="px-3 py-2">
{buchung.verwendungszweck}
{buchung.storniertVonId && (
<Badge variant="secondary" className="ml-2">
gegengebucht
</Badge>
)}
</td>
<td className="px-3 py-2">{buchung.kategorie.name}</td>
<td className="px-3 py-2">{buchung.konto.bezeichnung}</td>
<td
className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${
buchung.art === "AUSGABE" ? "text-[var(--destructive)]" : ""
}`}
>
{buchung.art === "AUSGABE" ? "−" : "+"}
{alsBetrag(buchung.betrag)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{darfBearbeiten && (
<KontoAnlegen
offen={kontoOffen}
schliessen={() => setKontoOffen(false)}
fehler={setMeldung}
fertig={() => {
setKontoOffen(false);
void werkzeuge.kassenkonto.invalidate();
}}
/>
)}
</div>
);
}
function Aufgabe({
titel,
symbol,
wert,
hinweis,
pfad,
hervorheben = false,
}: {
titel: string;
symbol: React.ReactNode;
wert: string;
hinweis: string;
pfad: string;
hervorheben?: boolean;
}) {
return (
<Link href={pfad} className="block">
<Card
className={`h-full transition-colors hover:bg-[var(--accent)] ${
hervorheben ? "border-[var(--pruefung-faellig)]" : ""
}`}
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
{symbol}
<CardTitle className="text-sm font-medium">{titel}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="font-medium">{wert}</p>
<p className="text-sm text-[var(--muted-foreground)]">{hinweis}</p>
</CardContent>
</Card>
</Link>
);
}
/** Ein Konto anlegen. */
function KontoAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [art, setArt] = React.useState<KontoArt>(KontoArt.BAR);
const [iban, setIban] = React.useState("");
const [anfangsbestand, setAnfangsbestand] = React.useState("0");
const anlegen = trpc.kassenkonto.anlegen.useMutation({
onSuccess: () => {
setBezeichnung("");
setIban("");
setAnfangsbestand("0");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Konto anlegen</DialogTitle>
<DialogDescription>
Der Anfangsbestand ist der Ausgangspunkt aller Bestände. Er lässt sich später nicht
mehr ändern, sobald darauf gebucht wurde — eine Korrektur läuft dann über eine
Buchung.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="konto-bezeichnung">Bezeichnung</Label>
<Input
id="konto-bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Barkasse Gerätehaus"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="konto-art">Art</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as KontoArt)}>
<SelectTrigger id="konto-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(KontoArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{KONTOART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="konto-bestand">Anfangsbestand</Label>
<Input
id="konto-bestand"
type="number"
step="0.01"
value={anfangsbestand}
onChange={(ereignis) => setAnfangsbestand(ereignis.target.value)}
/>
</div>
</div>
{art !== KontoArt.BAR && (
<div className="space-y-2">
<Label htmlFor="konto-iban">IBAN</Label>
<Input
id="konto-iban"
value={iban}
onChange={(ereignis) => setIban(ereignis.target.value.toUpperCase())}
placeholder="DE00 0000 0000 0000 0000 00"
className="font-mono"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Steht auf der Rechnung im Zahlungshinweis. In Listen wird sie gekürzt angezeigt.
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
bezeichnung,
art,
iban: iban.replace(/\s/g, "") || null,
anfangsbestand: Number(anfangsbestand) || 0,
aktiv: true,
})
}
disabled={!bezeichnung.trim() || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -178,7 +178,7 @@ const EINTRAEGE: Eintrag[] = [
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 8;
const AKTUELLE_PHASE = 9;
export interface SeitenleisteProps {
rechte: Recht[];
+405
View File
@@ -0,0 +1,405 @@
import { Prisma } from "@prisma/client";
import { BuchungsArt } from "@prisma/client";
import { runden } from "@/lib/kasse/betrag";
/**
* Kontoauszüge einlesen: CAMT.053 und MT940.
*
* Zwei Formate, weil jede Bank ein anderes für „das übliche" hält. CAMT.053
* ist XML nach ISO 20022 und der neuere Standard; MT940 ist eine
* Zeilenkodierung aus den Achtzigern, die Sparkassen und Volksbanken bis heute
* ausliefern. Eine Wehr bekommt, was ihre Bank hergibt, und beides muss gehen.
*
* **Was hier NICHT passiert: buchen.** Herauskommen Vorschläge, die jemand
* ansieht. Der Grund ist derselbe wie bei der Belegerfassung in Phase 8: ein
* Betrag, den eine Maschine aus einer Datei gelesen hat, ist keine Buchung,
* sondern ein ausgefülltes Formular. Und ein doppelt eingelesener Auszug wäre
* sonst ein doppelt gebuchter Monat.
*
* Selbst geschrieben statt über eine Bibliothek: für MT940 gibt es kaum
* gepflegte, und CAMT.053 braucht von seinen zweihundert Feldern genau sechs.
* Der Parser ist damit kürzer als die Einbindung — und in einer Anwendung ohne
* Netzzugang ist jede Abhängigkeit weniger eine Sorge weniger.
*
* Reines Auswerten ohne Datenbank — damit es sich prüfen lässt.
*/
export interface Auszugsposten {
/** Buchungstag. Bei MT940 das Valutadatum, wenn kein anderes dabeisteht. */
datum: Date;
art: BuchungsArt;
betrag: Prisma.Decimal;
/** Der Verwendungszweck, zusammengesetzt und von Umbrüchen befreit. */
verwendungszweck: string;
/** Wer überwiesen hat oder wem überwiesen wurde. */
gegenseite: string | null;
gegenIban: string | null;
/**
* Die Kennung der Bank für diesen Umsatz.
*
* Das Feld, an dem eine Doppelbuchung erkannt wird: derselbe Auszug zweimal
* eingelesen liefert dieselben Kennungen. Nicht jede Bank liefert eine —
* dann wird aus Datum, Betrag und Zweck eine gebildet.
*/
kennung: string;
}
export interface Auszug {
posten: Auszugsposten[];
/** Was nicht gelesen werden konnte, mit Grund. */
fehler: Array<{ stelle: string; grund: string }>;
/** IBAN des Kontos, wenn die Datei sie nennt. */
konto: string | null;
format: "CAMT.053" | "MT940";
}
// -----------------------------------------------------------------------------
// Gemeinsames
// -----------------------------------------------------------------------------
/** Aus Datum, Betrag und Zweck eine Kennung bilden, wo die Bank keine liefert. */
function ersatzkennung(datum: Date, betrag: string, zweck: string): string {
const tag = datum.toISOString().slice(0, 10);
// Der Zweck normalisiert, damit unterschiedliche Umbrüche derselben Zeile
// nicht zu zwei Kennungen führen.
const kern = zweck.replace(/\s+/g, " ").trim().slice(0, 60);
return `${tag}|${betrag}|${kern}`;
}
function alsDatum(jahr: number, monat: number, tag: number): Date | null {
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null;
const wert = new Date(Date.UTC(jahr, monat - 1, tag));
if (wert.getUTCMonth() !== monat - 1 || wert.getUTCDate() !== tag) return null;
return wert;
}
// -----------------------------------------------------------------------------
// CAMT.053
// -----------------------------------------------------------------------------
/**
* Ein Element aus einem XML-Ausschnitt holen.
*
* Bewusst mit regulären Ausdrücken und ohne XML-Parser: gebraucht werden sechs
* Felder aus einer flachen Struktur, die Datei kommt von der eigenen Bank, und
* ein Parser wäre eine Abhängigkeit für dreißig Zeilen. Namensräume werden
* dabei ignoriert (`<Ntry>` und `<ns:Ntry>` gleichermaßen) — genau das, was
* ein Parser hier auch täte.
*/
function feld(xml: string, name: string): string | null {
const treffer = new RegExp(`<(?:\\w+:)?${name}\\b[^>]*>([\\s\\S]*?)</(?:\\w+:)?${name}>`).exec(
xml,
);
return treffer?.[1]?.trim() ?? null;
}
function alleBloecke(xml: string, name: string): string[] {
const muster = new RegExp(`<(?:\\w+:)?${name}\\b[^>]*>([\\s\\S]*?)</(?:\\w+:)?${name}>`, "g");
return [...xml.matchAll(muster)].map((treffer) => treffer[1] ?? "");
}
export function camtLesen(inhalt: string): Auszug {
const posten: Auszugsposten[] = [];
const fehler: Array<{ stelle: string; grund: string }> = [];
const konto = feld(inhalt, "IBAN");
const eintraege = alleBloecke(inhalt, "Ntry");
if (eintraege.length === 0) {
fehler.push({
stelle: "Datei",
grund:
"Es wurde kein einziger Umsatz (<Ntry>) gefunden. Ist das wirklich eine " +
"CAMT.053-Datei?",
});
}
for (const [nummer, eintrag] of eintraege.entries()) {
const stelle = `Umsatz ${nummer + 1}`;
const rohBetrag = feld(eintrag, "Amt");
const richtung = feld(eintrag, "CdtDbtInd");
if (!rohBetrag || !richtung) {
fehler.push({ stelle, grund: "Betrag oder Richtung (CdtDbtInd) fehlt." });
continue;
}
// CRDT = Gutschrift, DBIT = Lastschrift. Andere Werte gibt es nicht.
const art =
richtung.toUpperCase() === "CRDT"
? BuchungsArt.EINNAHME
: richtung.toUpperCase() === "DBIT"
? BuchungsArt.AUSGABE
: null;
if (!art) {
fehler.push({ stelle, grund: `Unbekannte Richtung „${richtung}".` });
continue;
}
// Buchungstag bevorzugt, sonst Valuta.
const rohDatum =
feld(feld(eintrag, "BookgDt") ?? "", "Dt") ?? feld(feld(eintrag, "ValDt") ?? "", "Dt");
const teile = /^(\d{4})-(\d{2})-(\d{2})/.exec(rohDatum ?? "");
const datum = teile
? alsDatum(Number(teile[1]), Number(teile[2]), Number(teile[3]))
: null;
if (!datum) {
fehler.push({ stelle, grund: "Kein lesbares Buchungsdatum." });
continue;
}
let wert: Prisma.Decimal;
try {
wert = runden(new Prisma.Decimal(rohBetrag));
} catch {
fehler.push({ stelle, grund: `Betrag „${rohBetrag}" ist keine Zahl.` });
continue;
}
if (wert.lessThanOrEqualTo(0)) {
// Der Betrag ist in CAMT immer positiv; die Richtung steht daneben. Ein
// negativer Wert wäre ein Widerspruch in der Datei selbst.
fehler.push({ stelle, grund: "Betrag ist nicht positiv." });
continue;
}
// Der Verwendungszweck kann auf mehrere <Ustrd> verteilt sein.
const zweckteile = alleBloecke(eintrag, "Ustrd");
const verwendungszweck =
zweckteile.join(" ").replace(/\s+/g, " ").trim() ||
feld(eintrag, "AddtlNtryInf")?.replace(/\s+/g, " ").trim() ||
"(kein Verwendungszweck)";
// Die Gegenseite steht je nach Richtung unter Debtor oder Creditor.
const partei = art === BuchungsArt.EINNAHME ? "Dbtr" : "Cdtr";
const gegenseite = feld(feld(eintrag, partei) ?? "", "Nm");
const gegenIban = feld(feld(eintrag, `${partei}Acct`) ?? "", "IBAN");
const kennung =
feld(eintrag, "AcctSvcrRef") ??
feld(eintrag, "EndToEndId") ??
ersatzkennung(datum, wert.toFixed(2), verwendungszweck);
posten.push({ datum, art, betrag: wert, verwendungszweck, gegenseite, gegenIban, kennung });
}
return { posten, fehler, konto, format: "CAMT.053" };
}
// -----------------------------------------------------------------------------
// MT940
// -----------------------------------------------------------------------------
/**
* MT940 einlesen.
*
* Das Format ist eine Folge von Feldern `:NN:Inhalt`, jedes auf einer eigenen
* Zeile, Fortsetzungen ohne Doppelpunkt. Gebraucht werden zwei:
*
* :61: der Umsatz — Datum, Richtung, Betrag
* :86: der Verwendungszweck, in Unterfeldern `?NN`
*
* Die Zeile :61: ist eine Zeichenkette ohne Trennzeichen, was sie zum
* unangenehmsten Teil macht:
*
* 2605 2605 C 1234,56 NTRF ...
* └─┬┘ └─┬┘ │ └──┬──┘
* Valuta Buchung │ Betrag mit Komma
* (optional)
* Richtung: C, D, RC, RD
*/
export function mt940Lesen(inhalt: string): Auszug {
const posten: Auszugsposten[] = [];
const fehler: Array<{ stelle: string; grund: string }> = [];
let konto: string | null = null;
// Fortsetzungszeilen anhängen: eine neue Angabe beginnt immer mit ":NN:".
const zeilen: string[] = [];
for (const roh of inhalt.replace(/\r\n/g, "\n").split("\n")) {
if (/^:\d{2}[A-Z]?:/.test(roh)) zeilen.push(roh);
else if (zeilen.length > 0 && roh.trim()) zeilen[zeilen.length - 1] += roh;
}
let offen: Auszugsposten | null = null;
let nummer = 0;
const abschliessen = () => {
if (offen) {
posten.push(offen);
offen = null;
}
};
for (const zeile of zeilen) {
const kennsatz = /^:(\d{2}[A-Z]?):(.*)$/.exec(zeile);
if (!kennsatz) continue;
const [, art, rest = ""] = kennsatz;
// :25: Kontobezeichnung — enthält je nach Bank die IBAN.
if (art === "25") {
const iban = /[A-Z]{2}\d{2}[A-Z0-9]{10,30}/.exec(rest.replace(/\s/g, ""));
konto = iban?.[0] ?? konto;
continue;
}
if (art === "61") {
abschliessen();
nummer += 1;
const stelle = `Umsatz ${nummer}`;
// Valutadatum JJMMTT, dann optional Buchungsdatum MMTT, dann Richtung.
const treffer = /^(\d{6})(\d{4})?(RC|RD|C|D)([A-Z])?([\d.,]+)/.exec(rest);
if (!treffer) {
fehler.push({ stelle, grund: `Die Umsatzzeile ist nicht lesbar: „${rest.slice(0, 40)}"` });
continue;
}
const [, valuta, buchung, richtung, , rohBetrag = ""] = treffer;
const jahr = 2000 + Number(valuta!.slice(0, 2));
const valutaMonat = Number(valuta!.slice(2, 4));
const valutaTag = Number(valuta!.slice(4, 6));
// Das Buchungsdatum trägt kein Jahr. Liegt es weit vor dem Valutadatum,
// stammt es aus dem Vorjahr — das ist der Jahreswechsel im Auszug.
let datum: Date | null;
if (buchung) {
const monat = Number(buchung.slice(0, 2));
const tag = Number(buchung.slice(2, 4));
const jahrDerBuchung = monat > valutaMonat + 6 ? jahr - 1 : jahr;
datum = alsDatum(jahrDerBuchung, monat, tag);
} else {
datum = alsDatum(jahr, valutaMonat, valutaTag);
}
if (!datum) {
fehler.push({ stelle, grund: "Kein lesbares Datum in der Umsatzzeile." });
continue;
}
// C = Gutschrift, D = Lastschrift. RC und RD sind Rückbuchungen und
// kehren die Richtung um.
const buchungsart =
richtung === "C" || richtung === "RD" ? BuchungsArt.EINNAHME : BuchungsArt.AUSGABE;
let wert: Prisma.Decimal;
try {
wert = runden(new Prisma.Decimal(rohBetrag.replace(/\./g, "").replace(",", ".")));
} catch {
fehler.push({ stelle, grund: `Betrag „${rohBetrag}" ist keine Zahl.` });
continue;
}
if (wert.lessThanOrEqualTo(0)) {
fehler.push({ stelle, grund: "Betrag ist nicht positiv." });
continue;
}
offen = {
datum,
art: buchungsart,
betrag: wert,
verwendungszweck: "",
gegenseite: null,
gegenIban: null,
kennung: "",
};
continue;
}
if (art === "86" && offen) {
// Unterfelder ?20 bis ?29 sind der Verwendungszweck, ?32/?33 der Name
// der Gegenseite, ?31 ihre Kontonummer oder IBAN.
const felder = new Map<string, string>();
for (const teil of rest.split("?").slice(1)) {
const schluessel = teil.slice(0, 2);
const inhaltTeil = teil.slice(2);
felder.set(schluessel, (felder.get(schluessel) ?? "") + inhaltTeil);
}
const zweck = Array.from({ length: 10 }, (_, i) => felder.get(String(20 + i)) ?? "")
.join(" ")
.replace(/\s+/g, " ")
.trim();
const name = [felder.get("32"), felder.get("33")]
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.trim();
offen.verwendungszweck = zweck || rest.replace(/\s+/g, " ").trim() || "(kein Verwendungszweck)";
offen.gegenseite = name || null;
const iban = /[A-Z]{2}\d{2}[A-Z0-9]{10,30}/.exec((felder.get("31") ?? "").replace(/\s/g, ""));
offen.gegenIban = iban?.[0] ?? null;
offen.kennung = ersatzkennung(offen.datum, offen.betrag.toFixed(2), offen.verwendungszweck);
continue;
}
}
abschliessen();
// Ein Umsatz ohne :86: hat noch keine Kennung bekommen.
for (const eintrag of posten) {
if (!eintrag.verwendungszweck) eintrag.verwendungszweck = "(kein Verwendungszweck)";
if (!eintrag.kennung) {
eintrag.kennung = ersatzkennung(
eintrag.datum,
eintrag.betrag.toFixed(2),
eintrag.verwendungszweck,
);
}
}
if (posten.length === 0 && fehler.length === 0) {
fehler.push({
stelle: "Datei",
grund: "Es wurde kein einziger Umsatz (:61:) gefunden. Ist das wirklich eine MT940-Datei?",
});
}
return { posten, fehler, konto, format: "MT940" };
}
/**
* Das Format erkennen und einlesen.
*
* Erkannt wird am Inhalt und nicht an der Dateiendung: `.txt` und `.sta`
* kommen für MT940 vor, `.xml` und `.camt` für CAMT — und mancher Browser
* hängt beim Herunterladen noch etwas an.
*/
export function auszugLesen(inhalt: string): Auszug {
const anfang = inhalt.trimStart().slice(0, 400);
if (anfang.startsWith("<") || /<(?:\w+:)?Document\b/.test(anfang)) {
return camtLesen(inhalt);
}
if (/^:\d{2}[A-Z]?:/m.test(inhalt)) {
return mt940Lesen(inhalt);
}
return {
posten: [],
fehler: [
{
stelle: "Datei",
grund:
"Das Format wurde nicht erkannt. Erwartet wird CAMT.053 (XML) oder MT940 " +
"(Zeilen mit :20:, :61:, :86:). Beides bietet das Online-Banking zum " +
"Herunterladen an — oft unter „Umsatzexport“.",
},
],
konto: null,
format: "MT940",
};
}
+161
View File
@@ -0,0 +1,161 @@
import { Prisma } from "@prisma/client";
import { BuchungsArt } from "@prisma/client";
/**
* Rechnen mit Geld.
*
* **Niemals Fließkomma.** 0,1 + 0,2 ergibt in binärer Darstellung nicht 0,3,
* und eine Kasse, die sich um Cent verrechnet, ist keine Kasse — der
* Kassenprüfer findet die Abweichung, niemand kann sie erklären, und am Ende
* steht das Wort „Differenz" im Bericht.
*
* Gerechnet wird deshalb durchgängig mit `Prisma.Decimal` (decimal.js), das
* dieselbe Genauigkeit hat wie die Spalte `DECIMAL(12,2)` in der Datenbank.
*
* Reines Rechnen ohne Datenbank — damit es sich prüfen lässt.
*/
export const NULL_EURO = new Prisma.Decimal(0);
/** Ein Betrag aus einer Zahl, einem Text oder einem Decimal. */
export function betrag(wert: Prisma.Decimal | number | string | null | undefined): Prisma.Decimal {
if (wert === null || wert === undefined || wert === "") return NULL_EURO;
return new Prisma.Decimal(wert);
}
/**
* Auf zwei Stellen runden — kaufmännisch.
*
* Ein Zwischenergebnis mit drei Nachkommastellen entsteht bei jeder
* Multiplikation mit einer Menge (2,5 Stunden × 47,50 €). Gerundet wird erst
* am Ende, und zwar so, wie es eine Rechnung tut: ab der halben Stelle nach
* oben.
*/
export function runden(wert: Prisma.Decimal): Prisma.Decimal {
return wert.toDecimalPlaces(2, Prisma.Decimal.ROUND_HALF_UP);
}
/** Die Summe einer Liste. Leere Liste ergibt null Euro, nicht undefined. */
export function summe(werte: Array<Prisma.Decimal | number | string>): Prisma.Decimal {
return werte.reduce<Prisma.Decimal>((bisher, wert) => bisher.plus(betrag(wert)), NULL_EURO);
}
/**
* Was eine Buchung mit dem Bestand macht.
*
* Der Betrag ist immer positiv, die Richtung steht in `art`. Diese Funktion
* ist die eine Stelle, an der aus beidem ein Vorzeichen wird — damit es nicht
* an fünf Stellen unterschiedlich gemacht wird.
*/
export function alsWirkung(art: BuchungsArt, wert: Prisma.Decimal | number | string): Prisma.Decimal {
const zahl = betrag(wert);
return art === BuchungsArt.EINNAHME ? zahl : zahl.negated();
}
/**
* Der Bestand eines Kontos.
*
* Anfangsbestand plus die Wirkung aller Buchungen. Bewusst gerechnet und nicht
* gespeichert: ein gespeicherter Bestand neben einer nachgetragenen Buchung
* wäre falsch, ohne falsch auszusehen — und genau danach sucht ein
* Kassenprüfer.
*/
export function bestand(
anfangsbestand: Prisma.Decimal | number | string,
buchungen: Array<{ art: BuchungsArt; betrag: Prisma.Decimal | number | string }>,
): Prisma.Decimal {
return buchungen.reduce<Prisma.Decimal>(
(bisher, buchung) => bisher.plus(alsWirkung(buchung.art, buchung.betrag)),
betrag(anfangsbestand),
);
}
/**
* Einnahmen, Ausgaben und Saldo einer Liste.
*
* Die drei Zahlen, die in jeder Auswertung stehen. An einer Stelle gerechnet,
* damit sie zusammenpassen: Saldo ist immer Einnahmen minus Ausgaben und nie
* eine unabhängig gebildete Summe.
*/
export function bilanz(
buchungen: Array<{ art: BuchungsArt; betrag: Prisma.Decimal | number | string }>,
): { einnahmen: Prisma.Decimal; ausgaben: Prisma.Decimal; saldo: Prisma.Decimal } {
const einnahmen = summe(
buchungen.filter((b) => b.art === BuchungsArt.EINNAHME).map((b) => b.betrag),
);
const ausgaben = summe(
buchungen.filter((b) => b.art === BuchungsArt.AUSGABE).map((b) => b.betrag),
);
return { einnahmen, ausgaben, saldo: einnahmen.minus(ausgaben) };
}
/**
* Eine Position einer Rechnung.
*
* Menge mal Einzelpreis, auf zwei Stellen gerundet. Einsatzstunden werden
* viertelstündlich abgerechnet — 0,25 × 47,50 muss auf den Cent stimmen.
*/
export function positionssumme(
menge: Prisma.Decimal | number | string,
einzelpreis: Prisma.Decimal | number | string,
): Prisma.Decimal {
return runden(betrag(menge).times(betrag(einzelpreis)));
}
/**
* Einen Betrag aus einer Eingabe lesen.
*
* Deutsche und englische Schreibweise, mit und ohne Währungszeichen. Was sich
* nicht eindeutig lesen lässt, ergibt null — nicht null Euro, sondern
* „unlesbar". Der Unterschied ist wichtig: ein Betrag von null ist eine
* Angabe, ein unlesbarer ist eine Rückfrage.
*/
export function betragLesen(text: string): Prisma.Decimal | null {
const bereinigt = text
.replace(/[€$£]/g, "")
.replace(/\b(?:EUR|USD|CHF)\b/gi, "")
.replace(/\s/g, "")
.trim();
if (!bereinigt) return null;
// Das letzte Trennzeichen entscheidet: "1.234,56" ist tausendzweihundert,
// "1,234.56" ebenso — nur andersherum geschrieben.
const letztesKomma = bereinigt.lastIndexOf(",");
const letzterPunkt = bereinigt.lastIndexOf(".");
const normalisiert =
letztesKomma > letzterPunkt
? bereinigt.replace(/\./g, "").replace(",", ".")
: bereinigt.replace(/,/g, "");
if (!/^-?\d+(\.\d+)?$/.test(normalisiert)) return null;
try {
return runden(new Prisma.Decimal(normalisiert));
} catch {
return null;
}
}
/**
* Ein Betrag, wie er auf Papier steht.
*
* Eigene Funktion statt `alsEuro` aus lib/utils: die arbeitet mit `number`,
* und ein Decimal dorthin zu geben, wäre genau der Umweg über Fließkomma, den
* diese Datei vermeidet.
*/
export function alsText(wert: Prisma.Decimal | number | string | null | undefined): string {
if (wert === null || wert === undefined) return "—";
const zahl = betrag(wert);
const negativ = zahl.isNegative();
const [ganz, teil = "00"] = zahl.abs().toFixed(2).split(".");
// Tausenderpunkte von hinten setzen — die Formatierung von Intl würde den
// Decimal erst in eine Fließkommazahl verwandeln.
const mitPunkten = (ganz ?? "0").replace(/\B(?=(\d{3})+(?!\d))/g, ".");
return `${negativ ? "−" : ""}${mitPunkten},${teil} €`;
}
+359
View File
@@ -0,0 +1,359 @@
import "server-only";
import { html, htmlZuPdf } from "@/lib/pdf";
import { alsDatum } from "@/lib/utils";
import { alsText } from "@/lib/kasse/betrag";
import { ANLASS_TEXT, KONTOART_TEXT } from "@/lib/kasse/texte";
import type { KontoArt, RechnungsAnlass } from "@prisma/client";
/**
* Die beiden Papiere der Kasse: Rechnung und Kassenprüfbericht.
*
* Beide gehen aus dem Haus und werden von Leuten gelesen, die FLORIAN nicht
* kennen — der eine von einem Rechnungsempfänger, der andere von der
* Kassenprüfung der Gemeinde. Danach richtet sich alles:
*
* * **Alles ist eingebettet.** Keine externen Schriften, keine nachgeladenen
* Bilder. Ein Kassenprüfbericht muss in zehn Jahren noch genauso aussehen,
* auch wenn es dann keinen Server mehr gibt, der etwas nachliefert.
* * **Unterschriftsfelder gehören aufs Papier.** Ein Kassenprüfbericht ohne
* Platz für zwei Unterschriften ist kein Bericht, sondern ein Ausdruck.
* * **Beträge rechtsbündig und mit Tabellenziffern.** Eine Spalte mit
* Beträgen, die nicht untereinander stehen, prüft niemand gern.
*/
/**
* Die Fußzeile.
*
* Gotenberg erwartet dafür ein vollständiges HTML-Dokument mit eigenem Stil —
* die Regeln aus dem Hauptdokument gelten dort nicht. `pageNumber` und
* `totalPages` füllt es selbst.
*/
function fusszeile(text: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 18mm; }
</style></head><body>
<div>${html(text)} · Seite <span class="pageNumber"></span> von <span class="totalPages"></span></div>
</body></html>`;
}
/** Der gemeinsame Rahmen. Schriften nur aus dem, was jedes System hat. */
function rahmen(titel: string, inhalt: string): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>${html(titel)}</title>
<style>
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
* { box-sizing: border-box; }
body {
font-family: "Segoe UI", "DejaVu Sans", Arial, sans-serif;
font-size: 10.5pt;
line-height: 1.45;
color: #111;
margin: 0;
}
h1 { font-size: 16pt; margin: 0 0 2mm; }
h2 { font-size: 12pt; margin: 8mm 0 2mm; border-bottom: 0.4mm solid #111; padding-bottom: 1mm; }
table { width: 100%; border-collapse: collapse; margin-top: 2mm; }
th, td { padding: 1.6mm 2mm; text-align: left; vertical-align: top; }
thead th { border-bottom: 0.4mm solid #111; font-size: 9.5pt; }
tbody tr { border-bottom: 0.15mm solid #bbb; }
.zahl { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.summe td { border-top: 0.4mm solid #111; border-bottom: none; font-weight: 600; padding-top: 2.5mm; }
.klein { font-size: 9pt; color: #444; }
.kopf { display: flex; justify-content: space-between; gap: 10mm; margin-bottom: 8mm; }
.anschrift { margin: 10mm 0 8mm; line-height: 1.5; }
.hinweis { border: 0.3mm solid #111; padding: 3mm; margin-top: 6mm; }
.unterschriften { display: flex; gap: 12mm; margin-top: 18mm; }
.unterschrift { flex: 1; }
.linie { border-top: 0.3mm solid #111; margin-top: 14mm; padding-top: 1.5mm; font-size: 9pt; }
.differenz-ok { color: #15803d; font-weight: 600; }
.differenz-abweichung { color: #b91c1c; font-weight: 600; }
</style>
</head>
<body>${inhalt}</body>
</html>`;
}
// -----------------------------------------------------------------------------
// Rechnung
// -----------------------------------------------------------------------------
export interface RechnungsDaten {
wehr: {
name: string;
strasse: string | null;
plz: string | null;
ort: string | null;
telefon: string | null;
email: string | null;
};
nummer: string;
anlass: RechnungsAnlass;
datum: Date;
faelligAm: Date;
empfaenger: string;
empfaengerZusatz: string | null;
strasse: string | null;
plz: string | null;
ort: string | null;
bemerkung: string | null;
betrag: string;
positionen: Array<{
bezeichnung: string;
menge: string;
einheit: string | null;
einzelpreis: string;
gesamtpreis: string;
}>;
/** Bankverbindung für den Zahlungshinweis. */
bankkonto: { bezeichnung: string; iban: string | null } | null;
}
export async function rechnungPdf(daten: RechnungsDaten): Promise<Buffer> {
const absender = [
daten.wehr.name,
daten.wehr.strasse,
[daten.wehr.plz, daten.wehr.ort].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(" · ");
const positionen = daten.positionen
.map(
(position, stelle) => `
<tr>
<td class="zahl">${stelle + 1}</td>
<td>${html(position.bezeichnung)}</td>
<td class="zahl">${html(position.menge)}${position.einheit ? ` ${html(position.einheit)}` : ""}</td>
<td class="zahl">${html(alsText(position.einzelpreis))}</td>
<td class="zahl">${html(alsText(position.gesamtpreis))}</td>
</tr>`,
)
.join("");
const inhalt = `
<div class="kopf">
<div>
<h1>${html(daten.wehr.name)}</h1>
<div class="klein">
${html(daten.wehr.strasse ?? "")}<br>
${html([daten.wehr.plz, daten.wehr.ort].filter(Boolean).join(" "))}<br>
${daten.wehr.telefon ? `Telefon ${html(daten.wehr.telefon)}<br>` : ""}
${daten.wehr.email ? html(daten.wehr.email) : ""}
</div>
</div>
<div class="klein" style="text-align:right">
<strong>Rechnung ${html(daten.nummer)}</strong><br>
Datum: ${html(alsDatum(daten.datum))}<br>
Fällig: ${html(alsDatum(daten.faelligAm))}<br>
Anlass: ${html(ANLASS_TEXT[daten.anlass])}
</div>
</div>
<div class="klein" style="font-size:7.5pt; border-bottom:0.2mm solid #999; padding-bottom:1mm">
${html(absender)}
</div>
<div class="anschrift">
${html(daten.empfaenger)}<br>
${daten.empfaengerZusatz ? `${html(daten.empfaengerZusatz)}<br>` : ""}
${daten.strasse ? `${html(daten.strasse)}<br>` : ""}
${html([daten.plz, daten.ort].filter(Boolean).join(" "))}
</div>
<h2>Rechnung ${html(daten.nummer)}</h2>
${daten.bemerkung ? `<p>${html(daten.bemerkung)}</p>` : ""}
<table>
<thead>
<tr>
<th class="zahl">Pos.</th>
<th>Bezeichnung</th>
<th class="zahl">Menge</th>
<th class="zahl">Einzelpreis</th>
<th class="zahl">Gesamt</th>
</tr>
</thead>
<tbody>
${positionen}
<tr class="summe">
<td colspan="4">Rechnungsbetrag</td>
<td class="zahl">${html(alsText(daten.betrag))}</td>
</tr>
</tbody>
</table>
<div class="hinweis">
<strong>Zahlungshinweis</strong><br>
Bitte überweisen Sie den Betrag bis zum ${html(alsDatum(daten.faelligAm))} unter Angabe
der Rechnungsnummer ${html(daten.nummer)}.
${
daten.bankkonto?.iban
? `<br><br>${html(daten.bankkonto.bezeichnung)}<br>IBAN ${html(
daten.bankkonto.iban.replace(/(.{4})/g, "$1 ").trim(),
)}`
: ""
}
</div>
<p class="klein" style="margin-top:8mm">
Diese Rechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.
</p>
`;
return htmlZuPdf(rahmen(`Rechnung ${daten.nummer}`, inhalt), {
hintergrundDrucken: true,
fusszeileHtml: fusszeile(`${daten.wehr.name} · Rechnung ${daten.nummer}`),
});
}
// -----------------------------------------------------------------------------
// Kassenprüfbericht
// -----------------------------------------------------------------------------
export interface PruefberichtDaten {
wehr: { name: string; ort: string | null };
vonAm: Date;
bisAm: Date;
geprueftAm: Date | null;
pruefer: string | null;
zweiterPruefer: string | null;
bestandLautBuch: string;
gezaehlterBestand: string;
differenz: string;
erlaeuterung: string | null;
posten: Array<{
bezeichnung: string;
art: KontoArt;
bestandLautBuch: string;
gezaehlterBestand: string;
differenz: string;
bemerkung: string | null;
}>;
/** Bewegung im Zeitraum, über alle Konten. */
einnahmen: string;
ausgaben: string;
anzahlBuchungen: number;
}
export async function pruefberichtPdf(daten: PruefberichtDaten): Promise<Buffer> {
const stimmt = Number(daten.differenz) === 0;
const posten = daten.posten
.map(
(posten) => `
<tr>
<td>${html(posten.bezeichnung)}<div class="klein">${html(KONTOART_TEXT[posten.art])}</div></td>
<td class="zahl">${html(alsText(posten.bestandLautBuch))}</td>
<td class="zahl">${html(alsText(posten.gezaehlterBestand))}</td>
<td class="zahl ${Number(posten.differenz) === 0 ? "differenz-ok" : "differenz-abweichung"}">
${html(alsText(posten.differenz))}
</td>
<td>${html(posten.bemerkung ?? "")}</td>
</tr>`,
)
.join("");
const inhalt = `
<h1>Kassenprüfbericht</h1>
<div class="klein">
${html(daten.wehr.name)}${daten.wehr.ort ? `, ${html(daten.wehr.ort)}` : ""}<br>
Prüfzeitraum ${html(alsDatum(daten.vonAm))} bis ${html(alsDatum(daten.bisAm))}
${daten.geprueftAm ? ` · geprüft am ${html(alsDatum(daten.geprueftAm))}` : ""}
</div>
<h2>Bestände je Konto</h2>
<table>
<thead>
<tr>
<th>Konto</th>
<th class="zahl">laut Buch</th>
<th class="zahl">gezählt</th>
<th class="zahl">Differenz</th>
<th>Bemerkung</th>
</tr>
</thead>
<tbody>
${posten}
<tr class="summe">
<td>Gesamt</td>
<td class="zahl">${html(alsText(daten.bestandLautBuch))}</td>
<td class="zahl">${html(alsText(daten.gezaehlterBestand))}</td>
<td class="zahl ${stimmt ? "differenz-ok" : "differenz-abweichung"}">
${html(alsText(daten.differenz))}
</td>
<td></td>
</tr>
</tbody>
</table>
<h2>Bewegung im Prüfzeitraum</h2>
<table>
<tbody>
<tr><td>Einnahmen</td><td class="zahl">${html(alsText(daten.einnahmen))}</td></tr>
<tr><td>Ausgaben</td><td class="zahl">${html(alsText(daten.ausgaben))}</td></tr>
<tr class="summe">
<td>Saldo</td>
<td class="zahl">${html(alsText((Number(daten.einnahmen) - Number(daten.ausgaben)).toFixed(2)))}</td>
</tr>
</tbody>
</table>
<p class="klein">${daten.anzahlBuchungen} Buchungen im Prüfzeitraum.</p>
<h2>Feststellung</h2>
<div class="hinweis">
${
stimmt
? "Der gezählte Bestand stimmt mit dem Bestand laut Buch überein."
: `<strong>Es besteht eine Differenz von ${html(alsText(daten.differenz))}.</strong>`
}
${daten.erlaeuterung ? `<br><br>${html(daten.erlaeuterung).replace(/\n/g, "<br>")}` : ""}
</div>
<p style="margin-top:6mm">
Die Kassenprüfer bestätigen mit ihrer Unterschrift, dass sie die Kassenführung geprüft,
die Bestände gezählt und die Belege stichprobenweise eingesehen haben.
</p>
<div class="unterschriften">
<div class="unterschrift">
<div class="linie">
${html(daten.pruefer ?? "Kassenprüfer")}<br>
<span class="klein">Ort, Datum, Unterschrift</span>
</div>
</div>
<div class="unterschrift">
<div class="linie">
${html(daten.zweiterPruefer ?? "Kassenprüfer")}<br>
<span class="klein">Ort, Datum, Unterschrift</span>
</div>
</div>
</div>
<div class="unterschriften">
<div class="unterschrift">
<div class="linie">
Kassier<br>
<span class="klein">Ort, Datum, Unterschrift</span>
</div>
</div>
<div class="unterschrift">
<div class="linie">
Kommandant<br>
<span class="klein">Ort, Datum, Unterschrift</span>
</div>
</div>
</div>
`;
return htmlZuPdf(rahmen("Kassenprüfbericht", inhalt), {
hintergrundDrucken: true,
fusszeileHtml: fusszeile(
`${daten.wehr.name} · Kassenprüfbericht ${alsDatum(daten.vonAm)} – ${alsDatum(daten.bisAm)}`,
),
});
}
+107
View File
@@ -0,0 +1,107 @@
import type {
BeitragStand,
BuchungsArt,
KontoArt,
RechnungsAnlass,
RechnungStand,
} from "@prisma/client";
/**
* Klartext für die Kassenansichten.
*
* Eigene Datei, weil die Oberfläche sie im Browser braucht. Dieselbe Trennung
* wie bei den Texten der Phasen 4, 7 und 8.
*/
export const KONTOART_TEXT: Record<KontoArt, string> = {
BAR: "Barkasse",
BANK: "Bankkonto",
RUECKLAGE: "Rücklage",
};
export const BUCHUNGSART_TEXT: Record<BuchungsArt, string> = {
EINNAHME: "Einnahme",
AUSGABE: "Ausgabe",
};
export const BEITRAG_STAND_TEXT: Record<BeitragStand, string> = {
OFFEN: "Offen",
BEZAHLT: "Bezahlt",
ERLASSEN: "Erlassen",
GEMAHNT: "Gemahnt",
};
export const BEITRAG_STAND_VARIANTE: Record<
BeitragStand,
"einsatzbereit" | "pruefungFaellig" | "gesperrt" | "secondary"
> = {
OFFEN: "pruefungFaellig",
BEZAHLT: "einsatzbereit",
ERLASSEN: "secondary",
GEMAHNT: "gesperrt",
};
export const RECHNUNG_STAND_TEXT: Record<RechnungStand, string> = {
ENTWURF: "Entwurf",
VERSENDET: "Versendet",
BEZAHLT: "Bezahlt",
GEMAHNT: "Gemahnt",
STORNIERT: "Storniert",
};
export const RECHNUNG_STAND_VARIANTE: Record<
RechnungStand,
"einsatzbereit" | "pruefungFaellig" | "gesperrt" | "secondary" | "outline"
> = {
ENTWURF: "outline",
VERSENDET: "pruefungFaellig",
BEZAHLT: "einsatzbereit",
GEMAHNT: "gesperrt",
STORNIERT: "secondary",
};
export const ANLASS_TEXT: Record<RechnungsAnlass, string> = {
FEHLALARM: "Fehlalarm",
OELEINSATZ: "Öleinsatz",
VERANSTALTUNG: "Veranstaltung",
VERMIETUNG: "Vermietung",
SONSTIGES: "Sonstiges",
};
/**
* Ein Betrag, wie er auf Papier steht — für den Browser.
*
* Nimmt auch ein `Decimal` entgegen: Prisma reicht Beträge als solche durch,
* und `String()` darauf ergibt „12.5". Hier wird nur formatiert, nicht
* gerechnet — gerechnet wird auf dem Server, wo `Prisma.Decimal` mit seiner
* vollen Genauigkeit zur Verfügung steht. Ein Umweg über `Number` wäre genau
* der Schritt über Fließkomma, den die ganze Kasse vermeidet.
*/
export function alsBetrag(
wert: string | number | { toString(): string } | null | undefined,
): string {
if (wert === null || wert === undefined) return "—";
const text = String(wert);
const negativ = text.startsWith("-");
const [ganz = "0", teil = "00"] = text.replace("-", "").split(".");
const mitPunkten = ganz.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
const nachkomma = (teil + "00").slice(0, 2);
return `${negativ ? "−" : ""}${mitPunkten},${nachkomma} €`;
}
/** Eine IBAN gekürzt: DE89 …3000. Vollständig steht sie nur in den Stammdaten. */
export function ibanKurz(iban: string | null | undefined): string {
if (!iban) return "—";
const roh = iban.replace(/\s/g, "");
if (roh.length < 8) return roh;
return `${roh.slice(0, 4)} … ${roh.slice(-4)}`;
}
/** Eine IBAN in Vierergruppen, wie sie auf Papier steht. */
export function ibanFormatieren(iban: string | null | undefined): string {
if (!iban) return "—";
return iban.replace(/\s/g, "").replace(/(.{4})/g, "$1 ").trim();
}
+247
View File
@@ -0,0 +1,247 @@
import { BuchungsArt } from "@prisma/client";
/**
* Aus dem Verwendungszweck eine Kategorie vorschlagen.
*
* Der Kassier tippt „Tankstelle Musterhausen, Diesel LF" und bekommt
* „Kraftstoff" vorgeschlagen. Das spart bei zweihundert Buchungen im Jahr
* keine Stunde — aber es sorgt dafür, dass dieselbe Sache immer unter
* derselben Kategorie landet, und **das** ist es, was die Auswertung am Ende
* brauchbar macht.
*
* Zwei Wege, in dieser Reihenfolge:
*
* 1. **Was die Wehr schon gebucht hat.** Der beste Vorschlag kommt aus dem
* eigenen Bestand: wer „Stadtwerke" bisher immer auf „Wasser und Strom"
* gebucht hat, will es wieder.
* 2. **Stichwörter.** Für die erste Buchung einer Wehr, wenn es noch keinen
* Bestand gibt.
*
* Kein Sprachmodell. Das wäre für eine Kategoriezuordnung mit Kanonen auf
* Spatzen geschossen, es liefe nur mit eingerichtetem Ollama, und der Vergleich
* mit dem eigenen Bestand ist ohnehin besser: er kennt die Gepflogenheiten
* dieser Wehr.
*
* Reines Rechnen ohne Datenbank — damit es sich prüfen lässt.
*/
export interface Vorschlag {
kategorieId: string;
name: string;
/** 0 bis 1. Woher die Sicherheit kommt, steht in `grund`. */
sicherheit: number;
grund: string;
}
/** Eine frühere Buchung, aus der gelernt wird. */
export interface Vorbild {
kategorieId: string;
kategorieName: string;
verwendungszweck: string;
art: BuchungsArt;
}
/**
* Stichwörter für den Fall ohne Bestand.
*
* Bewusst kurz gehalten und auf das beschränkt, was in einer Feuerwehrkasse
* wirklich vorkommt. Eine lange Liste sähe gründlicher aus und träfe häufiger
* daneben — jeder Eintrag hier ist eine Behauptung darüber, wie diese Wehr
* ihre Kategorien nennt.
*/
const STICHWOERTER: Array<{ woerter: string[]; kategorie: string; art: BuchungsArt }> = [
{ woerter: ["diesel", "benzin", "tankstelle", "kraftstoff", "aral", "shell"], kategorie: "Kraftstoff", art: BuchungsArt.AUSGABE },
{ woerter: ["werkstatt", "reparatur", "instandsetzung", "ersatzteil"], kategorie: "Reparatur", art: BuchungsArt.AUSGABE },
{ woerter: ["tüv", "tuev", "hauptuntersuchung", "dekra"], kategorie: "Prüfungen", art: BuchungsArt.AUSGABE },
{ woerter: ["versicherung", "kfz-versicherung", "haftpflicht"], kategorie: "Versicherungen", art: BuchungsArt.AUSGABE },
{ woerter: ["strom", "wasser", "abwasser", "stadtwerke", "gas", "heizung"], kategorie: "Wasser und Strom", art: BuchungsArt.AUSGABE },
{ woerter: ["lehrgang", "ausbildung", "seminar", "feuerwehrschule"], kategorie: "Ausbildung", art: BuchungsArt.AUSGABE },
{ woerter: ["schutzkleidung", "einsatzjacke", "helm", "stiefel", "handschuh"], kategorie: "Schutzkleidung", art: BuchungsArt.AUSGABE },
{ woerter: ["atemschutz", "pressluftatmer", "maske", "filter"], kategorie: "Atemschutz", art: BuchungsArt.AUSGABE },
{ woerter: ["verpflegung", "getränke", "getraenke", "brotzeit", "metzger", "bäckerei", "baeckerei"], kategorie: "Verpflegung", art: BuchungsArt.AUSGABE },
{ woerter: ["porto", "büromaterial", "bueromaterial", "druckerpapier", "toner"], kategorie: "Verwaltung", art: BuchungsArt.AUSGABE },
{ woerter: ["kontoführung", "kontofuehrung", "gebühren", "gebuehren", "entgelt"], kategorie: "Bankgebühren", art: BuchungsArt.AUSGABE },
{ woerter: ["mitgliedsbeitrag", "beitrag", "jahresbeitrag"], kategorie: "Mitgliedsbeiträge", art: BuchungsArt.EINNAHME },
{ woerter: ["zuschuss", "zuwendung", "gemeinde", "kommune", "landkreis"], kategorie: "Zuschüsse", art: BuchungsArt.EINNAHME },
{ woerter: ["spende", "spendenaktion"], kategorie: "Spenden", art: BuchungsArt.EINNAHME },
{ woerter: ["fest", "waldfest", "ausschank", "losverkauf", "tombola"], kategorie: "Veranstaltungen", art: BuchungsArt.EINNAHME },
{ woerter: ["fehlalarm", "kostenersatz", "verursacher", "ölspur", "oelspur"], kategorie: "Kostenersatz", art: BuchungsArt.EINNAHME },
];
/** Wörter, die in fast jedem Verwendungszweck stehen und nichts unterscheiden. */
const FUELLWOERTER = new Set([
"rechnung",
"rechnungsnummer",
"nr",
"vom",
"für",
"fuer",
"und",
"der",
"die",
"das",
"den",
"dem",
"gmbh",
"kg",
"ohg",
"ag",
"ev",
"feuerwehr",
"ff",
"kundennummer",
"kdnr",
"datum",
"betrag",
"eur",
]);
/** Einen Text in vergleichbare Wörter zerlegen. */
export function woerter(text: string): string[] {
return text
.toLowerCase()
.replace(/[^a-zäöüß0-9\s-]/g, " ")
.split(/\s+/)
.map((wort) => wort.replace(/^-+|-+$/g, ""))
// Zwei Zeichen sagen nichts, reine Zahlen sind Rechnungsnummern.
.filter((wort) => wort.length >= 3 && !/^\d+$/.test(wort) && !FUELLWOERTER.has(wort));
}
/**
* Wie ähnlich sich zwei Verwendungszwecke sind.
*
* Anteil der gemeinsamen Wörter an der kürzeren der beiden Mengen. Damit
* schlägt „Stadtwerke Musterhausen Abschlag Strom" auf „Stadtwerke
* Musterhausen Jahresabrechnung" an, ohne dass eine lange Buchung eine kurze
* verdrängt.
*/
export function aehnlichkeit(a: string, b: string): number {
const mengeA = new Set(woerter(a));
const mengeB = new Set(woerter(b));
if (mengeA.size === 0 || mengeB.size === 0) return 0;
let gemeinsam = 0;
for (const wort of mengeA) if (mengeB.has(wort)) gemeinsam += 1;
return gemeinsam / Math.min(mengeA.size, mengeB.size);
}
/**
* Den besten Vorschlag finden.
*
* @param zweck was der Kassier eingetippt hat
* @param art Einnahme oder Ausgabe — schränkt die Kategorien ein
* @param vorbilder frühere Buchungen der Wehr
* @param kategorien was es überhaupt gibt, für den Weg über Stichwörter
*/
export function kategorieVorschlagen(
zweck: string,
art: BuchungsArt,
vorbilder: Vorbild[],
kategorien: Array<{ id: string; name: string; art: BuchungsArt }>,
): Vorschlag | null {
if (!zweck.trim()) return null;
// --- Weg 1: aus dem eigenen Bestand ---------------------------------------
//
// Je Kategorie den ähnlichsten Vorgänger nehmen, nicht den Durchschnitt: eine
// Kategorie mit fünfzig gemischten Buchungen wäre sonst gegenüber einer mit
// zwei sehr passenden im Nachteil.
const beste = new Map<string, { wert: number; name: string; zweck: string }>();
for (const vorbild of vorbilder) {
if (vorbild.art !== art) continue;
const wert = aehnlichkeit(zweck, vorbild.verwendungszweck);
const bisher = beste.get(vorbild.kategorieId);
if (!bisher || wert > bisher.wert) {
beste.set(vorbild.kategorieId, {
wert,
name: vorbild.kategorieName,
zweck: vorbild.verwendungszweck,
});
}
}
let bester: { id: string; wert: number; name: string; zweck: string } | null = null;
for (const [id, eintrag] of beste) {
if (!bester || eintrag.wert > bester.wert) bester = { id, ...eintrag };
}
// Unter der Hälfte gemeinsamer Wörter ist es Zufall. Ein falscher Vorschlag
// ist schlechter als keiner: er wird übernommen, ohne gelesen zu werden.
if (bester && bester.wert >= 0.5) {
return {
kategorieId: bester.id,
name: bester.name,
sicherheit: Math.min(1, bester.wert),
grund: `zuletzt so gebucht: „${bester.zweck.slice(0, 60)}"`,
};
}
// --- Weg 2: Stichwörter ---------------------------------------------------
const gefunden = woerter(zweck);
for (const eintrag of STICHWOERTER) {
if (eintrag.art !== art) continue;
if (!eintrag.woerter.some((wort) => gefunden.includes(wort))) continue;
const kategorie = kategorien.find(
(k) => k.art === art && k.name.toLowerCase() === eintrag.kategorie.toLowerCase(),
);
if (kategorie) {
return {
kategorieId: kategorie.id,
name: kategorie.name,
// Niedriger als ein Treffer aus dem Bestand: ein Stichwort weiß
// nichts über die Gepflogenheiten dieser Wehr.
sicherheit: 0.5,
grund: "aus dem Verwendungszweck geschlossen",
};
}
}
return null;
}
/**
* Die Kategorien, die eine Wehr am Anfang bekommt.
*
* Sie stehen hier und nicht nur im Seed, weil eine neu angelegte Wehr sie
* ebenso braucht — und weil die Stichwortliste oben sich auf genau diese Namen
* bezieht. Beides auseinanderlaufen zu lassen wäre die sichere Art, den
* Vorschlag stillschweigend kaputtzumachen.
*/
export const STANDARDKATEGORIEN: Array<{
name: string;
art: BuchungsArt;
oberkategorie?: string;
}> = [
{ name: "Fahrzeuge", art: BuchungsArt.AUSGABE },
{ name: "Kraftstoff", art: BuchungsArt.AUSGABE, oberkategorie: "Fahrzeuge" },
{ name: "Reparatur", art: BuchungsArt.AUSGABE, oberkategorie: "Fahrzeuge" },
{ name: "Prüfungen", art: BuchungsArt.AUSGABE, oberkategorie: "Fahrzeuge" },
{ name: "Versicherungen", art: BuchungsArt.AUSGABE },
{ name: "Ausrüstung", art: BuchungsArt.AUSGABE },
{ name: "Schutzkleidung", art: BuchungsArt.AUSGABE, oberkategorie: "Ausrüstung" },
{ name: "Atemschutz", art: BuchungsArt.AUSGABE, oberkategorie: "Ausrüstung" },
{ name: "Gerätehaus", art: BuchungsArt.AUSGABE },
{ name: "Wasser und Strom", art: BuchungsArt.AUSGABE, oberkategorie: "Gerätehaus" },
{ name: "Ausbildung", art: BuchungsArt.AUSGABE },
{ name: "Verpflegung", art: BuchungsArt.AUSGABE },
{ name: "Verwaltung", art: BuchungsArt.AUSGABE },
{ name: "Bankgebühren", art: BuchungsArt.AUSGABE, oberkategorie: "Verwaltung" },
{ name: "Mitgliedsbeiträge", art: BuchungsArt.EINNAHME },
{ name: "Zuschüsse", art: BuchungsArt.EINNAHME },
{ name: "Spenden", art: BuchungsArt.EINNAHME },
{ name: "Veranstaltungen", art: BuchungsArt.EINNAHME },
{ name: "Kostenersatz", art: BuchungsArt.EINNAHME },
];
+4
View File
@@ -30,6 +30,10 @@ export const AUFTRAG = {
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
/** Erzeugt das Atemschutzprotokoll als PDF (Phase 5). */
ATEMSCHUTZPROTOKOLL_ERZEUGEN: "atemschutzprotokoll.erzeugen",
/** Erzeugt eine Rechnung als PDF (Phase 9). */
RECHNUNG_ERZEUGEN: "rechnung.erzeugen",
/** Erzeugt den Kassenprüfbericht als PDF (Phase 9). */
KASSENPRUEFBERICHT_ERZEUGEN: "kassenpruefbericht.erzeugen",
/** Bettet eine Wiki-Seite für die semantische Suche ein (Phase 8). */
WIKI_EINBETTEN: "wiki.einbetten",
/** Wandelt eine Diktataufnahme in Text (Phase 8). */
+13
View File
@@ -29,6 +29,11 @@ import { benachrichtigungRouter } from "@/server/routers/termin/benachrichtigung
import { dienstausweisRouter } from "@/server/routers/stammdaten/dienstausweis";
import { wikiRouter } from "@/server/routers/ki/wiki";
import { assistentRouter } from "@/server/routers/ki/assistent";
import { kassenkontoRouter, buchungskategorieRouter } from "@/server/routers/kasse/konto";
import { buchungRouter } from "@/server/routers/kasse/buchung";
import { beitragRouter } from "@/server/routers/kasse/beitrag";
import { rechnungRouter } from "@/server/routers/kasse/rechnung";
import { abschlussRouter } from "@/server/routers/kasse/abschluss";
/**
* Wurzel-Router.
@@ -195,6 +200,14 @@ export const appRouter = router({
// Phase 8 — Lokale KI
wiki: wikiRouter,
assistent: assistentRouter,
// Phase 9 — Kassenverwaltung
kassenkonto: kassenkontoRouter,
buchungskategorie: buchungskategorieRouter,
buchung: buchungRouter,
beitrag: beitragRouter,
rechnung: rechnungRouter,
kassenabschluss: abschlussRouter,
});
export type AppRouter = typeof appRouter;
+554
View File
@@ -0,0 +1,554 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BuchungsArt, Prisma } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { alsText, bestand, bilanz, summe } from "@/lib/kasse/betrag";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
/**
* Kassenabschluss und Auswertungen.
*
* Der Kassenabschluss ist 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.
*
* Drei Festlegungen:
*
* * **Der Bestand laut Buch wird gerechnet, nicht eingegeben.** Er ist
* Anfangsbestand plus die Wirkung aller Buchungen bis zum Stichtag. Eine
* Eingabe wäre die Stelle, an der eine Differenz verschwindet.
* * **Die Differenz ist das Ergebnis, nicht die Eingabe.** Gezählt minus
* laut Buch. Auch das erzwingt die Datenbank.
* * **Festschreiben schließt den Zeitraum.** Danach nimmt die Buchungsmaske
* kein Datum daraus mehr an, und der Abschluss selbst ist unveränderbar.
*
* Je Konto eine Zeile. Eine einzelne Summe über alles würde verbergen, auf
* welchem Konto die Differenz sitzt — und genau das ist die Frage, die ein
* Kassenprüfer stellt.
*/
export const abschlussRouter = router({
/**
* Was ein Abschluss für einen Zeitraum ergäbe.
*
* Wird vor dem Anlegen angezeigt: je Konto der Bestand laut Buch, damit die
* Prüfer wissen, wogegen sie zählen. Die gezählten Werte tragen sie danach
* ein.
*/
vorbereiten: mitRecht("kasse.abschluss")
.input(z.object({ vonAm: z.date(), bisAm: z.date() }))
.query(async ({ ctx, input }) => {
if (input.bisAm < input.vonAm) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Der Zeitraum endet vor seinem Beginn." });
}
return ctx.db(async (db) => {
const konten = await db.kassenkonto.findMany({
where: { geloeschtAm: null },
orderBy: [{ art: "asc" }, { bezeichnung: "asc" }],
});
const posten = [];
for (const konto of konten) {
// Alles bis zum Stichtag, nicht nur der Zeitraum: der Bestand ist
// eine Momentaufnahme und keine Periodensumme.
const summen = await db.buchung.groupBy({
by: ["art"],
where: { kontoId: konto.id, datum: { lte: input.bisAm } },
_sum: { betrag: true },
});
// Zusätzlich die Bewegung im Zeitraum — das ist die Zahl, die im
// Bericht neben dem Bestand steht.
const imZeitraum = await db.buchung.groupBy({
by: ["art"],
where: { kontoId: konto.id, datum: { gte: input.vonAm, lte: input.bisAm } },
_sum: { betrag: true },
});
const zeitraum = bilanz(
imZeitraum.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
);
posten.push({
kontoId: konto.id,
bezeichnung: konto.bezeichnung,
art: konto.art,
bestandLautBuch: bestand(
konto.anfangsbestand,
summen.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
).toFixed(2),
einnahmen: zeitraum.einnahmen.toFixed(2),
ausgaben: zeitraum.ausgaben.toFixed(2),
});
}
const offene = await db.buchung.count({
where: { festgeschriebenAm: null, datum: { lte: input.bisAm } },
});
return {
posten,
gesamt: summe(posten.map((eintrag) => eintrag.bestandLautBuch)).toFixed(2),
/**
* Noch nicht festgeschriebene Buchungen im Zeitraum.
*
* Der wichtigste Hinweis dieser Ansicht: wer abschließt, während
* noch Entwürfe herumliegen, prüft einen Stand, der sich morgen
* ändert.
*/
offeneBuchungen: offene,
};
});
}),
liste: mitRecht("kasse.lesen").query(async ({ ctx }) =>
ctx.db((db) =>
db.kassenabschluss.findMany({
orderBy: { bisAm: "desc" },
take: 50,
include: {
pruefer: { select: { vorname: true, nachname: true } },
zweiterPruefer: { select: { vorname: true, nachname: true } },
berichtDatei: { select: { id: true, dateiname: true } },
_count: { select: { posten: true } },
},
}),
),
),
einzeln: mitRecht("kasse.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const abschluss = await ctx.db((db) =>
db.kassenabschluss.findUnique({
where: { id: input.id },
include: {
pruefer: { select: { vorname: true, nachname: true, dienstgrad: true } },
zweiterPruefer: { select: { vorname: true, nachname: true, dienstgrad: true } },
berichtDatei: { select: { id: true, dateiname: true } },
posten: {
orderBy: { konto: { bezeichnung: "asc" } },
include: { konto: { select: { bezeichnung: true, art: true } } },
},
},
}),
);
if (!abschluss) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Abschluss gibt es nicht." });
}
return abschluss;
}),
/**
* Einen Abschluss anlegen.
*
* Noch nicht festgeschrieben: die Prüfer können ihre Zahlen korrigieren,
* solange sie beisammensitzen. Erst das Festschreiben schließt den Zeitraum.
*/
anlegen: mitRecht("kasse.abschluss")
.input(
z.object({
vonAm: z.date(),
bisAm: z.date(),
prueferId: z.string().uuid().nullish(),
zweiterPrueferId: z.string().uuid().nullish(),
geprueftAm: z.date().nullish(),
erlaeuterung: z.string().trim().max(2000).nullish(),
posten: z
.array(
z.object({
kontoId: z.string().uuid(),
gezaehlterBestand: z.number().min(-1_000_000).max(10_000_000),
bemerkung: z.string().trim().max(500).nullish(),
}),
)
.min(1)
.max(50),
}),
)
.mutation(async ({ ctx, input }) => {
if (input.bisAm < input.vonAm) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Der Zeitraum endet vor seinem Beginn." });
}
return ctx.db(async (db) => {
const ueberschneidung = await db.kassenabschluss.findFirst({
where: {
festgeschriebenAm: { not: null },
vonAm: { lte: input.bisAm },
bisAm: { gte: input.vonAm },
},
select: { vonAm: true, bisAm: true },
});
if (ueberschneidung) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Der Zeitraum überschneidet sich mit einem bereits geprüften ` +
`(${ueberschneidung.vonAm.toISOString().slice(0, 10)} bis ` +
`${ueberschneidung.bisAm.toISOString().slice(0, 10)}). ` +
"Ein Zeitraum wird genau einmal geprüft.",
});
}
// Der Bestand laut Buch wird hier gerechnet und nicht übernommen. Eine
// Eingabe wäre die Stelle, an der eine Differenz verschwindet.
const posten = [];
let summeLautBuch = new Prisma.Decimal(0);
let summeGezaehlt = new Prisma.Decimal(0);
for (const eingabe of input.posten) {
const konto = await db.kassenkonto.findUnique({
where: { id: eingabe.kontoId },
select: { anfangsbestand: true, bezeichnung: true },
});
if (!konto) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Eines der angegebenen Konten gibt es nicht.",
});
}
const summen = await db.buchung.groupBy({
by: ["art"],
where: { kontoId: eingabe.kontoId, datum: { lte: input.bisAm } },
_sum: { betrag: true },
});
const lautBuch = bestand(
konto.anfangsbestand,
summen.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
);
const gezaehlt = new Prisma.Decimal(eingabe.gezaehlterBestand);
posten.push({
organisationId: ctx.sitzung.organisationId,
kontoId: eingabe.kontoId,
bestandLautBuch: lautBuch,
gezaehlterBestand: gezaehlt,
differenz: gezaehlt.minus(lautBuch),
bemerkung: eingabe.bemerkung ?? null,
});
summeLautBuch = summeLautBuch.plus(lautBuch);
summeGezaehlt = summeGezaehlt.plus(gezaehlt);
}
const abschluss = await db.kassenabschluss.create({
data: {
organisationId: ctx.sitzung.organisationId,
vonAm: input.vonAm,
bisAm: input.bisAm,
bestandLautBuch: summeLautBuch,
gezaehlterBestand: summeGezaehlt,
differenz: summeGezaehlt.minus(summeLautBuch),
erlaeuterung: input.erlaeuterung ?? null,
prueferId: input.prueferId ?? null,
zweiterPrueferId: input.zweiterPrueferId ?? null,
geprueftAm: input.geprueftAm ?? null,
posten: { create: posten },
},
include: { posten: true },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Kassenabschluss",
entitaetId: abschluss.id,
beschreibung:
`Kassenabschluss ${input.vonAm.toISOString().slice(0, 10)} bis ` +
`${input.bisAm.toISOString().slice(0, 10)}: laut Buch ${alsText(summeLautBuch)}, ` +
`gezählt ${alsText(summeGezaehlt)}`,
nachher: {
bestandLautBuch: summeLautBuch.toFixed(2),
gezaehlterBestand: summeGezaehlt.toFixed(2),
differenz: summeGezaehlt.minus(summeLautBuch).toFixed(2),
},
});
return abschluss;
});
}),
/**
* Festschreiben.
*
* Der Zeitraum ist danach zu — die Buchungsmaske nimmt kein Datum daraus
* mehr an, und der Abschluss selbst lässt sich nicht mehr ändern. Beides
* setzt die Datenbank durch.
*
* Eine Differenz ohne Erklärung wird abgewiesen. Das ist der Punkt, an dem
* eine Kassenprüfung sonst hängen bleibt: „stimmt nicht" steht im Bericht,
* und in fünf Jahren weiß niemand mehr, warum.
*/
festschreiben: mitRecht("kasse.abschluss")
.input(idEingabe)
.mutation(async ({ ctx, input }) => {
const abschluss = await ctx.db(async (db) => {
const vorher = await db.kassenabschluss.findUnique({ where: { id: input.id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Abschluss gibt es nicht." });
}
if (vorher.festgeschriebenAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieser Abschluss ist bereits festgeschrieben.",
});
}
if (!vorher.prueferId || !vorher.geprueftAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ohne Kassenprüfer und Prüfdatum ist es kein Abschluss, sondern eine Notiz.",
});
}
if (!vorher.differenz.isZero() && !vorher.erlaeuterung?.trim()) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Es besteht eine Differenz von ${alsText(vorher.differenz)}. Bitte erklären, ` +
"woher sie kommt — sonst steht in fünf Jahren „stimmt nicht“ im Bericht und " +
"niemand weiß mehr, warum.",
});
}
// Noch offene Buchungen im Zeitraum mit festschreiben: sonst ließe
// sich eine davon danach nicht mehr anfassen, weil der Zeitraum zu
// ist — und sie stünde für immer als Entwurf da.
const offene = await db.buchung.updateMany({
where: {
festgeschriebenAm: null,
datum: { gte: vorher.vonAm, lte: vorher.bisAm },
},
data: { festgeschriebenAm: new Date() },
});
const nachher = await db.kassenabschluss.update({
where: { id: input.id },
data: { festgeschriebenAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Kassenabschluss",
entitaetId: input.id,
beschreibung:
`Kassenabschluss festgeschrieben — der Zeitraum ${vorher.vonAm.toISOString().slice(0, 10)} ` +
`bis ${vorher.bisAm.toISOString().slice(0, 10)} ist geschlossen ` +
`(${offene.count} Buchungen mit festgeschrieben)`,
vorher: { festgeschriebenAm: null },
nachher: { festgeschriebenAm: nachher.festgeschriebenAm, mitFestgeschrieben: offene.count },
});
return nachher;
});
await beauftrage(AUFTRAG.KASSENPRUEFBERICHT_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
abschlussId: abschluss.id,
}).catch((fehler) => {
console.error("[kasse] Berichtsauftrag ließ sich nicht einstellen:", fehler);
});
return abschluss;
}),
// ---------------------------------------------------------------------------
// Auswertungen
// ---------------------------------------------------------------------------
/**
* Einnahmen und Ausgaben nach Kategorie und Monat.
*
* Die Zahlen für die Jahreshauptversammlung — und die einzige Ansicht, in
* der auffällt, dass die Kraftstoffkosten sich verdoppelt haben.
*/
auswertung: mitRecht("kasse.lesen")
.input(z.object({ jahr: z.number().int().min(1900).max(2200) }))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const von = new Date(Date.UTC(input.jahr, 0, 1));
const bis = new Date(Date.UTC(input.jahr, 11, 31));
const buchungen = await db.buchung.findMany({
where: { datum: { gte: von, lte: bis } },
select: {
datum: true,
art: true,
betrag: true,
kategorie: {
select: { id: true, name: true, oberkategorie: { select: { name: true } } },
},
},
});
// Nach Kategorie.
const jeKategorie = new Map<
string,
{ name: string; ober: string | null; art: BuchungsArt; summe: Prisma.Decimal }
>();
for (const buchung of buchungen) {
const bisher = jeKategorie.get(buchung.kategorie.id);
if (bisher) {
bisher.summe = bisher.summe.plus(buchung.betrag);
} else {
jeKategorie.set(buchung.kategorie.id, {
name: buchung.kategorie.name,
ober: buchung.kategorie.oberkategorie?.name ?? null,
art: buchung.art,
summe: new Prisma.Decimal(buchung.betrag),
});
}
}
// Nach Monat. Zwölf Zeilen, auch wenn in einem Monat nichts war —
// eine Lücke in der Reihe sähe aus wie ein Fehler.
const monate = Array.from({ length: 12 }, (_, monat) => {
const imMonat = buchungen.filter((buchung) => buchung.datum.getUTCMonth() === monat);
const werte = bilanz(imMonat);
return {
monat: monat + 1,
einnahmen: werte.einnahmen.toFixed(2),
ausgaben: werte.ausgaben.toFixed(2),
saldo: werte.saldo.toFixed(2),
};
});
const gesamt = bilanz(buchungen);
// Das Vorjahr daneben: eine Zahl ohne Vergleich sagt nichts.
const vorjahr = await db.buchung.groupBy({
by: ["art"],
where: {
datum: {
gte: new Date(Date.UTC(input.jahr - 1, 0, 1)),
lte: new Date(Date.UTC(input.jahr - 1, 11, 31)),
},
},
_sum: { betrag: true },
});
const vorjahrWerte = bilanz(
vorjahr.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
);
return {
jahr: input.jahr,
kategorien: [...jeKategorie.entries()]
.map(([id, eintrag]) => ({
id,
name: eintrag.name,
oberkategorie: eintrag.ober,
art: eintrag.art,
summe: eintrag.summe.toFixed(2),
}))
.sort((a, b) => Number(b.summe) - Number(a.summe)),
monate,
gesamt: {
einnahmen: gesamt.einnahmen.toFixed(2),
ausgaben: gesamt.ausgaben.toFixed(2),
saldo: gesamt.saldo.toFixed(2),
},
vorjahr: {
einnahmen: vorjahrWerte.einnahmen.toFixed(2),
ausgaben: vorjahrWerte.ausgaben.toFixed(2),
saldo: vorjahrWerte.saldo.toFixed(2),
},
anzahl: buchungen.length,
};
}),
),
/**
* Der Export für Steuerberater und Kämmerei.
*
* CSV 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.
*/
export: mitRecht("kasse.lesen")
.input(z.object({ jahr: z.number().int().min(1900).max(2200) }))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const buchungen = await db.buchung.findMany({
where: {
datum: {
gte: new Date(Date.UTC(input.jahr, 0, 1)),
lte: new Date(Date.UTC(input.jahr, 11, 31)),
},
},
orderBy: [{ datum: "asc" }, { erstelltAm: "asc" }],
include: {
konto: { select: { bezeichnung: true } },
kategorie: {
select: {
name: true,
kontonummer: true,
oberkategorie: { select: { name: true } },
},
},
person: { select: { vorname: true, nachname: true } },
},
});
const kopf = [
"Datum",
"Konto",
"Art",
"Betrag",
"Oberkategorie",
"Kategorie",
"Kontonummer",
"Verwendungszweck",
"Belegnummer",
"Gegenüber",
"Festgeschrieben",
];
const zeilen = buchungen.map((buchung) => [
buchung.datum.toISOString().slice(0, 10),
buchung.konto.bezeichnung,
buchung.art === BuchungsArt.EINNAHME ? "Einnahme" : "Ausgabe",
// Komma als Dezimaltrennzeichen — so erwartet es Excel hierzulande.
buchung.betrag.toFixed(2).replace(".", ","),
buchung.kategorie.oberkategorie?.name ?? "",
buchung.kategorie.name,
buchung.kategorie.kontonummer ?? "",
buchung.verwendungszweck,
buchung.belegnummer ?? "",
buchung.person
? `${buchung.person.nachname}, ${buchung.person.vorname}`
: (buchung.lieferant ?? ""),
buchung.festgeschriebenAm ? "ja" : "nein",
]);
/** Semikolon trennt, Anführungszeichen werden verdoppelt. */
const zeile = (felder: string[]) =>
felder
.map((feld) => (/[";\n]/.test(feld) ? `"${feld.replace(/"/g, '""')}"` : feld))
.join(";");
return {
dateiname: `kassenbuch-${input.jahr}.csv`,
// Das BOM sagt Excel, dass es UTF-8 ist. Ohne es werden aus Umlauten
// Fragezeichen, und die Datei kommt zurück.
inhalt: `${[zeile(kopf), ...zeilen.map(zeile)].join("\r\n")}\r\n`,
zeilen: zeilen.length,
};
}),
),
});
+625
View File
@@ -0,0 +1,625 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BeitragStand, BuchungsArt, Prisma } from "@prisma/client";
import { router, mitRecht, type MandantenAbfrage } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { alsText, summe } from "@/lib/kasse/betrag";
/**
* Mitgliedsbeiträge.
*
* Der Vorgang, der dem Kassier im Januar den meisten Ärger macht: hundert
* Mitglieder, drei Abteilungen mit verschiedenen Sätzen, und am Ende die Frage,
* wer noch nicht gezahlt hat.
*
* Drei Festlegungen:
*
* * **Der Beitragslauf legt an, was fehlt.** Zweimal ausgeführt entsteht
* nichts doppelt (Eindeutigkeit über Person und Jahr). Damit lässt er sich
* im Februar wiederholen, wenn drei Neue dazugekommen sind.
* * **Ein Satz gilt ab einem Jahr und wird nicht rückwirkend geändert.**
* Sonst stimmen die Beiträge vergangener Jahre nicht mehr, und die sind
* bereits geprüft.
* * **„Bezahlt" entsteht durch eine Buchung.** Ein Häkchen ohne Geld in der
* Kasse wäre eine Behauptung; die Buchung ist der Beleg.
*/
const satzFelder = z.object({
bezeichnung: z.string().trim().min(1).max(120),
betrag: z.number().min(0).max(100_000),
abteilung: z.string().trim().max(60).nullish(),
abJahr: z.number().int().min(1900).max(2200),
aktiv: z.boolean().default(true),
});
export const beitragRouter = router({
// ---------------------------------------------------------------------------
// Sätze
// ---------------------------------------------------------------------------
saetze: mitRecht("kasse.lesen")
.input(z.object({ jahr: z.number().int().optional() }).optional())
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.beitragssatz.findMany({
where: { ...(input?.jahr ? { abJahr: { lte: input.jahr } } : {}) },
orderBy: [{ abJahr: "desc" }, { abteilung: "asc" }],
include: { _count: { select: { beitraege: true } } },
}),
),
),
satzAnlegen: mitRecht("kasse.bearbeiten")
.input(satzFelder)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorhanden = await db.beitragssatz.findFirst({
where: { abteilung: input.abteilung ?? null, abJahr: input.abJahr },
select: { id: true },
});
if (vorhanden) {
throw new TRPCError({
code: "CONFLICT",
message: `Für ${input.abteilung ?? "alle Abteilungen"} gibt es ab ${input.abJahr} bereits einen Satz.`,
});
}
const satz = await db.beitragssatz.create({
data: { ...input, organisationId: ctx.sitzung.organisationId },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Beitragssatz",
entitaetId: satz.id,
beschreibung:
`Beitragssatz „${input.bezeichnung}": ${alsText(input.betrag)} ab ${input.abJahr}` +
(input.abteilung ? ` (${input.abteilung})` : ""),
nachher: { ...input },
});
return satz;
}),
),
satzAendern: mitRecht("kasse.bearbeiten")
.input(satzFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const { id, ...felder } = input;
return ctx.db(async (db) => {
const vorher = await db.beitragssatz.findUnique({
where: { id },
include: { _count: { select: { beitraege: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Satz gibt es nicht." });
}
// Ein Satz, nach dem schon abgerechnet wurde, ändert seinen Betrag
// nicht mehr: sonst stimmen die Beiträge vergangener Jahre nicht mehr,
// und die sind längst geprüft.
if (
felder.betrag !== undefined &&
!vorher.betrag.equals(felder.betrag) &&
vorher._count.beitraege > 0
) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Nach diesem Satz sind bereits ${vorher._count.beitraege} Beiträge abgerechnet. ` +
"Für eine Änderung bitte einen neuen Satz ab dem kommenden Jahr anlegen — " +
"so bleiben die bisherigen Jahre nachvollziehbar.",
});
}
const nachher = await db.beitragssatz.update({ where: { id }, data: felder });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Beitragssatz",
entitaetId: id,
beschreibung: `Beitragssatz „${nachher.bezeichnung}" geändert`,
vorher: vorher as unknown as Record<string, unknown>,
nachher: nachher as unknown as Record<string, unknown>,
});
return nachher;
});
}),
// ---------------------------------------------------------------------------
// Der Lauf
// ---------------------------------------------------------------------------
/**
* Wie ein Beitragslauf ausginge.
*
* Wird vor dem Anlegen angezeigt. Wer hundert Beiträge erzeugt, soll vorher
* sehen, wie viele es werden und wer keinen Satz hat — die Liste ohne Satz
* ist der interessante Teil.
*/
laufVorschau: mitRecht("kasse.bearbeiten")
.input(z.object({ jahr: z.number().int().min(1900).max(2200) }))
.query(async ({ ctx, input }) => vorschauRechnen(ctx.db, input.jahr)),
/**
* Den Beitragslauf ausführen.
*
* Legt an, was fehlt. Wiederholt ausgeführt entsteht nichts doppelt — die
* Eindeutigkeit über Person und Jahr sorgt dafür. Genau deshalb lässt er
* sich im Februar noch einmal starten, wenn drei Neue dazugekommen sind.
*/
lauf: mitRecht("kasse.bearbeiten")
.input(z.object({ jahr: z.number().int().min(1900).max(2200) }))
.mutation(async ({ ctx, input }) => {
const vorschau = await vorschauRechnen(ctx.db, input.jahr);
if (vorschau.anzulegen.length === 0) {
return { angelegt: 0, ohneSatz: vorschau.ohneSatz.length };
}
return ctx.db(async (db) => {
const ergebnis = await db.beitrag.createMany({
data: vorschau.anzulegen.map((eintrag) => ({
organisationId: ctx.sitzung.organisationId,
personId: eintrag.personId,
jahr: input.jahr,
satzId: eintrag.satzId,
betrag: eintrag.betrag,
})),
// Wer schon einen hat, behält ihn.
skipDuplicates: true,
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Beitrag",
beschreibung: `Beitragslauf ${input.jahr}: ${ergebnis.count} Beiträge angelegt`,
nachher: {
jahr: input.jahr,
angelegt: ergebnis.count,
ohneSatz: vorschau.ohneSatz.length,
},
});
return { angelegt: ergebnis.count, ohneSatz: vorschau.ohneSatz.length };
});
}),
// ---------------------------------------------------------------------------
// Die Liste
// ---------------------------------------------------------------------------
liste: mitRecht("kasse.lesen")
.input(
z.object({
jahr: z.number().int().min(1900).max(2200),
stand: z.nativeEnum(BeitragStand).optional(),
suche: z.string().trim().max(120).optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const bedingung: Prisma.BeitragWhereInput = {
jahr: input.jahr,
...(input.stand ? { stand: input.stand } : {}),
...(input.suche
? {
person: {
OR: [
{ vorname: { contains: input.suche, mode: "insensitive" } },
{ nachname: { contains: input.suche, mode: "insensitive" } },
{ mitgliedsnummer: { contains: input.suche, mode: "insensitive" } },
],
},
}
: {}),
};
const [beitraege, alle] = await Promise.all([
db.beitrag.findMany({
where: bedingung,
orderBy: [{ person: { nachname: "asc" } }, { person: { vorname: "asc" } }],
take: 1000,
include: {
person: {
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
abteilung: true,
},
},
satz: { select: { bezeichnung: true } },
},
}),
db.beitrag.findMany({
where: { jahr: input.jahr },
select: { betrag: true, stand: true },
}),
]);
const nach = (stand: BeitragStand) => alle.filter((eintrag) => eintrag.stand === stand);
return {
beitraege,
kennzahlen: {
gesamt: summe(alle.map((eintrag) => eintrag.betrag)).toFixed(2),
bezahlt: summe(nach(BeitragStand.BEZAHLT).map((e) => e.betrag)).toFixed(2),
offen: summe(
[...nach(BeitragStand.OFFEN), ...nach(BeitragStand.GEMAHNT)].map((e) => e.betrag),
).toFixed(2),
erlassen: summe(nach(BeitragStand.ERLASSEN).map((e) => e.betrag)).toFixed(2),
anzahlOffen: nach(BeitragStand.OFFEN).length + nach(BeitragStand.GEMAHNT).length,
},
};
}),
),
/**
* Einen Beitrag als bezahlt vermerken.
*
* Erzeugt zugleich die Buchung. Ein Häkchen ohne Geld in der Kasse wäre eine
* Behauptung — die Buchung ist der Beleg, und beim Kassenabschluss stimmt
* die Summe nur, wenn beides zusammenhängt.
*/
bezahlen: mitRecht("kasse.bearbeiten")
.input(
z.object({
id: z.string().uuid(),
kontoId: z.string().uuid(),
kategorieId: z.string().uuid(),
bezahltAm: z.date(),
/** Abweichender Betrag, etwa bei einer Teilzahlung oder Aufrundung. */
betrag: z.number().positive().max(100_000).optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const beitrag = await db.beitrag.findUnique({
where: { id: input.id },
include: { person: { select: { vorname: true, nachname: true, mitgliedsnummer: true } } },
});
if (!beitrag) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Beitrag gibt es nicht." });
}
if (beitrag.stand === BeitragStand.BEZAHLT) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Dieser Beitrag ist bezahlt." });
}
if (beitrag.stand === BeitragStand.ERLASSEN) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieser Beitrag wurde erlassen. Für eine Zahlung bitte zuerst zurücknehmen.",
});
}
const betragWert = input.betrag ?? Number(beitrag.betrag);
const buchung = await db.buchung.create({
data: {
organisationId: ctx.sitzung.organisationId,
kontoId: input.kontoId,
kategorieId: input.kategorieId,
datum: input.bezahltAm,
art: BuchungsArt.EINNAHME,
betrag: betragWert,
verwendungszweck: `Mitgliedsbeitrag ${beitrag.jahr} — ${beitrag.person.nachname}, ${beitrag.person.vorname}`,
personId: beitrag.personId,
erfasstVonId: ctx.sitzung.personId,
},
select: { id: true },
});
const nachher = await db.beitrag.update({
where: { id: input.id },
data: {
stand: BeitragStand.BEZAHLT,
bezahltAm: input.bezahltAm,
buchungId: buchung.id,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Beitrag",
entitaetId: input.id,
beschreibung:
`Beitrag ${beitrag.jahr} von ${beitrag.person.nachname} als bezahlt vermerkt ` +
`(${alsText(betragWert)})`,
vorher: { stand: beitrag.stand },
nachher: { stand: BeitragStand.BEZAHLT, buchungId: buchung.id },
});
return nachher;
}),
),
/** Einen Beitrag erlassen — mit Begründung, die Datenbank verlangt sie. */
erlassen: mitRecht("kasse.bearbeiten")
.input(z.object({ id: z.string().uuid(), grund: z.string().trim().min(3).max(300) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const beitrag = await db.beitrag.findUnique({
where: { id: input.id },
include: { person: { select: { nachname: true, vorname: true } } },
});
if (!beitrag) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Beitrag gibt es nicht." });
}
if (beitrag.stand === BeitragStand.BEZAHLT) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ein bezahlter Beitrag lässt sich nicht erlassen — das Geld ist in der Kasse.",
});
}
const nachher = await db.beitrag.update({
where: { id: input.id },
data: { stand: BeitragStand.ERLASSEN, erlassGrund: input.grund },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Beitrag",
entitaetId: input.id,
beschreibung: `Beitrag ${beitrag.jahr} von ${beitrag.person.nachname} erlassen: ${input.grund}`,
vorher: { stand: beitrag.stand },
nachher: { stand: BeitragStand.ERLASSEN, grund: input.grund },
});
return nachher;
}),
),
/**
* Mahnen.
*
* Als Sammelvorgang über eine Auswahl: einzeln zu mahnen ist bei zwanzig
* Rückständen eine Arbeit, die liegen bleibt — und ein nie gemahnter
* Rückstand steht in fünf Jahren immer noch da.
*/
mahnen: mitRecht("kasse.bearbeiten")
.input(z.object({ ids: z.array(z.string().uuid()).min(1).max(500) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const betroffen = await db.beitrag.findMany({
where: { id: { in: input.ids }, stand: { in: [BeitragStand.OFFEN, BeitragStand.GEMAHNT] } },
select: { id: true, mahnstufe: true },
});
const jetzt = new Date();
let gemahnt = 0;
for (const eintrag of betroffen) {
// Bei fünf ist Schluss: was danach kommt, ist keine Mahnung mehr,
// sondern eine Entscheidung der Kommandantschaft.
if (eintrag.mahnstufe >= 5) continue;
await db.beitrag.update({
where: { id: eintrag.id },
data: {
stand: BeitragStand.GEMAHNT,
mahnstufe: eintrag.mahnstufe + 1,
gemahntAm: jetzt,
},
});
gemahnt += 1;
}
if (gemahnt > 0) {
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Beitrag",
beschreibung: `${gemahnt} Beiträge gemahnt`,
nachher: { anzahl: gemahnt, gemahntAm: jetzt },
});
}
return { gemahnt, uebersprungen: betroffen.length - gemahnt };
}),
),
/**
* Die Rückstände über alle Jahre.
*
* Die Liste für die Jahreshauptversammlung — und die einzige Stelle, an der
* auffällt, dass jemand seit drei Jahren nicht gezahlt hat.
*/
rueckstaende: mitRecht("kasse.lesen").query(async ({ ctx }) =>
ctx.db(async (db) => {
const offene = await db.beitrag.findMany({
where: { stand: { in: [BeitragStand.OFFEN, BeitragStand.GEMAHNT] } },
orderBy: [{ person: { nachname: "asc" } }, { jahr: "asc" }],
include: {
person: {
select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true, aktiv: true },
},
},
});
// Nach Person bündeln: „Huber, Andreas — 3 Jahre, 135 €" sagt mehr als
// drei einzelne Zeilen.
const jePerson = new Map<
string,
{
person: (typeof offene)[number]["person"];
jahre: number[];
summe: Prisma.Decimal;
hoechsteMahnstufe: number;
}
>();
for (const eintrag of offene) {
const bisher = jePerson.get(eintrag.personId);
if (bisher) {
bisher.jahre.push(eintrag.jahr);
bisher.summe = bisher.summe.plus(eintrag.betrag);
bisher.hoechsteMahnstufe = Math.max(bisher.hoechsteMahnstufe, eintrag.mahnstufe);
} else {
jePerson.set(eintrag.personId, {
person: eintrag.person,
jahre: [eintrag.jahr],
summe: new Prisma.Decimal(eintrag.betrag),
hoechsteMahnstufe: eintrag.mahnstufe,
});
}
}
const zeilen = [...jePerson.values()]
.map((eintrag) => ({
person: eintrag.person,
jahre: eintrag.jahre,
summe: eintrag.summe.toFixed(2),
hoechsteMahnstufe: eintrag.hoechsteMahnstufe,
}))
.sort((a, b) => Number(b.summe) - Number(a.summe));
return {
zeilen,
gesamt: summe(offene.map((eintrag) => eintrag.betrag)).toFixed(2),
anzahl: offene.length,
};
}),
),
/** Eine Zahlung zurücknehmen — sie war ein Irrtum. */
zuruecknehmen: mitRecht("kasse.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const beitrag = await db.beitrag.findUnique({ where: { id: input.id } });
if (!beitrag) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Beitrag gibt es nicht." });
}
if (beitrag.stand === BeitragStand.OFFEN) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Dieser Beitrag ist bereits offen." });
}
// Die Buchung bleibt, wo sie ist. Wenn sie falsch war, wird sie
// gegengebucht — hier steht nur der Beitrag wieder auf offen.
const nachher = await db.beitrag.update({
where: { id: input.id },
data: {
stand: BeitragStand.OFFEN,
bezahltAm: null,
buchungId: null,
erlassGrund: null,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Beitrag",
entitaetId: input.id,
beschreibung: `Beitrag ${beitrag.jahr} wieder auf offen gesetzt`,
vorher: { stand: beitrag.stand, buchungId: beitrag.buchungId },
nachher: { stand: BeitragStand.OFFEN },
kontext: beitrag.buchungId
? { hinweis: "Die zugehörige Buchung bleibt bestehen und ist ggf. gegenzubuchen." }
: null,
});
return nachher;
}),
),
});
/**
* Was ein Beitragslauf für ein Jahr ergäbe.
*
* An einer Stelle, weil Vorschau und Ausführung dieselbe Rechnung brauchen —
* zwei Umsetzungen davon liefen früher oder später auseinander, und dann zeigte
* die Vorschau etwas anderes an, als hinterher entsteht.
*/
async function vorschauRechnen(abfrage: MandantenAbfrage, jahr: number) {
return abfrage(async (db) => {
const [personen, saetze, vorhandene] = await Promise.all([
db.person.findMany({
where: { aktiv: true, geloeschtAm: null },
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
abteilung: true,
},
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
}),
db.beitragssatz.findMany({
where: { aktiv: true, abJahr: { lte: jahr } },
orderBy: { abJahr: "desc" },
}),
db.beitrag.findMany({ where: { jahr }, select: { personId: true } }),
]);
const schonDa = new Set(vorhandene.map((eintrag) => eintrag.personId));
/** Der jüngste Satz, der für diese Abteilung schon gilt. */
const satzFuer = (abteilung: string | null) => {
const passend = saetze.find((satz) => satz.abteilung === abteilung);
// Kein eigener Satz? Dann der allgemeine ohne Abteilung.
return passend ?? saetze.find((satz) => satz.abteilung === null) ?? null;
};
const anzulegen: Array<{
personId: string;
name: string;
abteilung: string | null;
satzId: string;
betrag: number;
}> = [];
const ohneSatz: Array<{ personId: string; name: string; abteilung: string | null }> = [];
for (const person of personen) {
if (schonDa.has(person.id)) continue;
const satz = satzFuer(person.abteilung);
const name = `${person.nachname}, ${person.vorname}`;
if (!satz) {
ohneSatz.push({ personId: person.id, name, abteilung: person.abteilung });
continue;
}
anzulegen.push({
personId: person.id,
name,
abteilung: person.abteilung,
satzId: satz.id,
betrag: Number(satz.betrag),
});
}
return {
jahr,
anzulegen,
ohneSatz,
bereitsVorhanden: schonDa.size,
summe: anzulegen.reduce((bisher, eintrag) => bisher + eintrag.betrag, 0).toFixed(2),
};
});
}
+717
View File
@@ -0,0 +1,717 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BuchungsArt, KiStand, Prisma } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { alsText, bilanz } from "@/lib/kasse/betrag";
import { auszugLesen } from "@/lib/kasse/bankimport";
import { kategorieVorschlagen } from "@/lib/kasse/vorschlag";
/**
* Buchungen — der Kern der Kasse.
*
* Drei Regeln bestimmen alles hier, und alle drei stehen zusätzlich in der
* Datenbank:
*
* 1. **Eine festgeschriebene Buchung ist unveränderbar.** Korrekturen laufen
* über eine Gegenbuchung mit Verweis. Ein Kassenbuch, in dem man
* rückwirkend ändern kann, ist vor der Kassenprüfung wertlos.
* 2. **Ein geprüfter Zeitraum ist zu.** Nach dem Kassenabschluss nimmt die
* Maske kein Datum daraus mehr an — sonst hätte der geprüfte Bestand am
* Tag danach eine andere Zahl.
* 3. **Der Betrag ist positiv, die Richtung steht daneben.** Ein
* vorzeichenbehafteter Betrag wäre kürzer und beim ersten Tippfehler
* falsch.
*
* Der Bankimport liefert **Vorschläge**, keine Buchungen. Ein Betrag, den eine
* Maschine aus einer Datei gelesen hat, ist ein ausgefülltes Formular — und
* ein doppelt eingelesener Auszug wäre sonst ein doppelt gebuchter Monat.
*/
const buchungFelder = z.object({
kontoId: z.string().uuid(),
kategorieId: z.string().uuid(),
datum: z.date(),
art: z.nativeEnum(BuchungsArt),
betrag: z.number().positive().max(10_000_000),
verwendungszweck: z.string().trim().min(1).max(300),
belegnummer: z.string().trim().max(60).nullish(),
personId: z.string().uuid().nullish(),
lieferant: z.string().trim().max(200).nullish(),
gegenkontoId: z.string().uuid().nullish(),
belegDateiId: z.string().uuid().nullish(),
});
const listenauswahl = {
id: true,
datum: true,
art: true,
betrag: true,
verwendungszweck: true,
belegnummer: true,
lieferant: true,
festgeschriebenAm: true,
storniertVonId: true,
belegDateiId: true,
konto: { select: { id: true, bezeichnung: true, art: true } },
kategorie: { select: { id: true, name: true, art: true } },
person: { select: { id: true, vorname: true, nachname: true } },
} as const;
/** Was an einer Buchung fachlich nicht zusammenpasst. */
function widersprueche(daten: {
personId?: string | null;
lieferant?: string | null;
kontoId?: string;
gegenkontoId?: string | null;
}): string | null {
if (daten.personId && daten.lieferant) {
return "Entweder ein Mitglied oder ein Dritter — beides zusammen lässt sich nicht auswerten.";
}
if (daten.gegenkontoId && daten.gegenkontoId === daten.kontoId) {
return "Eine Umbuchung auf dasselbe Konto ist keine.";
}
return null;
}
export const buchungRouter = router({
liste: mitRecht("kasse.lesen")
.input(
z
.object({
suche: z.string().trim().max(200).optional(),
kontoId: z.string().uuid().optional(),
kategorieId: z.string().uuid().optional(),
art: z.nativeEnum(BuchungsArt).optional(),
von: z.date().optional(),
bis: z.date().optional(),
nurOffene: z.boolean().default(false),
anzahl: z.number().int().min(1).max(500).default(100),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const bedingung: Prisma.BuchungWhereInput = {
...(input?.kontoId ? { kontoId: input.kontoId } : {}),
...(input?.kategorieId ? { kategorieId: input.kategorieId } : {}),
...(input?.art ? { art: input.art } : {}),
...(input?.nurOffene ? { festgeschriebenAm: null } : {}),
...(input?.von || input?.bis
? {
datum: {
...(input.von ? { gte: input.von } : {}),
...(input.bis ? { lte: input.bis } : {}),
},
}
: {}),
...(input?.suche
? {
OR: [
{ verwendungszweck: { contains: input.suche, mode: "insensitive" } },
{ lieferant: { contains: input.suche, mode: "insensitive" } },
{ belegnummer: { contains: input.suche, mode: "insensitive" } },
],
}
: {}),
};
const [buchungen, summen] = await Promise.all([
db.buchung.findMany({
where: bedingung,
orderBy: [{ datum: "desc" }, { erstelltAm: "desc" }],
take: input?.anzahl ?? 100,
select: listenauswahl,
}),
// Die Summe über ALLE Treffer, nicht nur über die angezeigten. Sonst
// stünde unter einer auf hundert Zeilen begrenzten Liste eine
// Summe, die nichts bedeutet.
db.buchung.groupBy({ by: ["art"], where: bedingung, _sum: { betrag: true } }),
]);
const gesamt = bilanz(
summen.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
);
return {
buchungen,
summe: {
einnahmen: gesamt.einnahmen.toFixed(2),
ausgaben: gesamt.ausgaben.toFixed(2),
saldo: gesamt.saldo.toFixed(2),
},
};
}),
),
einzeln: mitRecht("kasse.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const buchung = await ctx.db((db) =>
db.buchung.findUnique({
where: { id: input.id },
include: {
konto: true,
kategorie: { include: { oberkategorie: { select: { name: true } } } },
person: { select: { id: true, vorname: true, nachname: true } },
erfasstVon: { select: { vorname: true, nachname: true } },
belegDatei: { select: { id: true, dateiname: true, mimeTyp: true } },
storniertVon: { select: { id: true, datum: true, verwendungszweck: true } },
storniert: { select: { id: true, datum: true, verwendungszweck: true } },
},
}),
);
if (!buchung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Buchung gibt es nicht." });
}
return buchung;
}),
/**
* Eine Kategorie vorschlagen.
*
* Wird beim Tippen aufgerufen. Der beste Vorschlag kommt aus dem eigenen
* Bestand — wer „Stadtwerke" bisher immer auf „Wasser und Strom" gebucht
* hat, will es wieder.
*/
vorschlag: mitRecht("kasse.bearbeiten")
.input(
z.object({
verwendungszweck: z.string().trim().min(3).max(300),
art: z.nativeEnum(BuchungsArt),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const [vorbilder, kategorien] = await Promise.all([
db.buchung.findMany({
where: { art: input.art },
orderBy: { datum: "desc" },
take: 400,
select: {
kategorieId: true,
verwendungszweck: true,
art: true,
kategorie: { select: { name: true } },
},
}),
db.buchungskategorie.findMany({
where: { aktiv: true, geloeschtAm: null, art: input.art },
select: { id: true, name: true, art: true },
}),
]);
return kategorieVorschlagen(
input.verwendungszweck,
input.art,
vorbilder.map((zeile) => ({
kategorieId: zeile.kategorieId,
kategorieName: zeile.kategorie.name,
verwendungszweck: zeile.verwendungszweck,
art: zeile.art,
})),
kategorien,
);
}),
),
anlegen: mitRecht("kasse.bearbeiten")
.input(buchungFelder)
.mutation(async ({ ctx, input }) => {
const problem = widersprueche(input);
if (problem) throw new TRPCError({ code: "BAD_REQUEST", message: problem });
return ctx.db(async (db) => {
const kategorie = await db.buchungskategorie.findUnique({
where: { id: input.kategorieId },
select: { art: true, name: true, aktiv: true },
});
if (!kategorie) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Kategorie gibt es nicht." });
}
if (kategorie.art !== input.art) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `„${kategorie.name}" ist eine Kategorie für ${
kategorie.art === BuchungsArt.EINNAHME ? "Einnahmen" : "Ausgaben"
}.`,
});
}
const buchung = await db.buchung.create({
data: {
...input,
organisationId: ctx.sitzung.organisationId,
erfasstVonId: ctx.sitzung.personId,
},
select: listenauswahl,
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchung",
entitaetId: buchung.id,
beschreibung:
`${input.art === BuchungsArt.EINNAHME ? "Einnahme" : "Ausgabe"} über ` +
`${alsText(input.betrag)}: ${input.verwendungszweck}`,
nachher: { ...input, betrag: input.betrag },
});
return buchung;
});
}),
aendern: mitRecht("kasse.bearbeiten")
.input(buchungFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const { id, ...felder } = input;
return ctx.db(async (db) => {
const vorher = await db.buchung.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Buchung gibt es nicht." });
}
// Die Datenbank würde es ohnehin abweisen — die Meldung hier ist die,
// die ein Mensch liest.
if (vorher.festgeschriebenAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Buchung ist festgeschrieben und lässt sich nicht mehr ändern. " +
"Für eine Korrektur bitte gegenbuchen — dann bleiben beide Vorgänge " +
"nachvollziehbar.",
});
}
const zusammen = { ...vorher, ...felder };
const problem = widersprueche(zusammen);
if (problem) throw new TRPCError({ code: "BAD_REQUEST", message: problem });
const nachher = await db.buchung.update({ where: { id }, data: felder });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Buchung",
entitaetId: id,
beschreibung: `Buchung geändert: ${nachher.verwendungszweck}`,
vorher: vorher as unknown as Record<string, unknown>,
nachher: nachher as unknown as Record<string, unknown>,
});
return nachher;
});
}),
/**
* Eine noch nicht festgeschriebene Buchung entfernen.
*
* Solange nichts festgeschrieben ist, ist eine Buchung ein Entwurf, und ein
* Entwurf darf verschwinden. Danach nicht mehr — dafür gibt es die
* Gegenbuchung.
*/
loeschen: mitRecht("kasse.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const buchung = await db.buchung.findUnique({
where: { id: input.id },
select: { festgeschriebenAm: true, verwendungszweck: true, betrag: true, art: true },
});
if (!buchung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Buchung gibt es nicht." });
}
if (buchung.festgeschriebenAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Buchung ist festgeschrieben. Bitte gegenbuchen statt löschen — ein " +
"Kassenbuch mit Lücken ist keines.",
});
}
await db.buchung.delete({ where: { id: input.id } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GELOESCHT,
entitaet: "Buchung",
entitaetId: input.id,
beschreibung: `Entwurf verworfen: ${buchung.verwendungszweck}`,
vorher: {
verwendungszweck: buchung.verwendungszweck,
betrag: buchung.betrag.toFixed(2),
art: buchung.art,
},
});
return { erfolgreich: true };
}),
),
/**
* Buchungen festschreiben.
*
* Der Schritt, nach dem nichts mehr geändert wird. Üblicherweise am Ende
* eines Monats für alles, was bis dahin erfasst wurde.
*
* Bewusst als Sammelvorgang: einzeln festzuschreiben ist bei zweihundert
* Buchungen im Jahr eine Arbeit, die niemand macht — und was nie
* festgeschrieben wird, ist nie geprüft.
*/
festschreiben: mitRecht("kasse.bearbeiten")
.input(
z.object({
ids: z.array(z.string().uuid()).min(1).max(1000).optional(),
/** Oder pauschal alles bis zu diesem Tag. */
bisAm: z.date().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
if (!input.ids && !input.bisAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Bitte angeben, was festgeschrieben werden soll — einzelne oder bis zu einem Tag.",
});
}
return ctx.db(async (db) => {
const jetzt = new Date();
const ergebnis = await db.buchung.updateMany({
where: {
festgeschriebenAm: null,
...(input.ids ? { id: { in: input.ids } } : {}),
...(input.bisAm ? { datum: { lte: input.bisAm } } : {}),
},
data: { festgeschriebenAm: jetzt },
});
if (ergebnis.count > 0) {
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Buchung",
beschreibung:
`${ergebnis.count} Buchungen festgeschrieben` +
(input.bisAm ? ` (bis ${input.bisAm.toISOString().slice(0, 10)})` : ""),
nachher: { anzahl: ergebnis.count, festgeschriebenAm: jetzt },
});
}
return { festgeschrieben: ergebnis.count };
});
}),
/**
* Gegenbuchen.
*
* Der einzige Weg, eine festgeschriebene Buchung aufzuheben: eine zweite mit
* derselben Summe in die andere Richtung, mit Verweis auf die erste. Beide
* bleiben stehen, die Summe stimmt wieder, und im Kassenbuch ist zu sehen,
* dass etwas berichtigt wurde — genau das will ein Kassenprüfer sehen.
*
* Das Datum der Gegenbuchung ist **heute**, nicht das der ursprünglichen:
* ein geprüfter Zeitraum bleibt zu.
*/
gegenbuchen: mitRecht("kasse.bearbeiten")
.input(
z.object({
id: z.string().uuid(),
grund: z.string().trim().min(3).max(200),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const original = await db.buchung.findUnique({
where: { id: input.id },
include: { konto: { select: { bezeichnung: true } } },
});
if (!original) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Buchung gibt es nicht." });
}
if (original.storniertVonId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Diese Buchung ist bereits gegengebucht.",
});
}
const gegenrichtung =
original.art === BuchungsArt.EINNAHME ? BuchungsArt.AUSGABE : BuchungsArt.EINNAHME;
const gegenbuchung = await db.buchung.create({
data: {
organisationId: ctx.sitzung.organisationId,
kontoId: original.kontoId,
kategorieId: original.kategorieId,
// Heute, nicht das Datum des Originals: ein geprüfter Zeitraum
// bleibt zu, und die Berichtigung gehört in den Monat, in dem sie
// aufgefallen ist.
datum: new Date(),
art: gegenrichtung,
betrag: original.betrag,
verwendungszweck: `Gegenbuchung zu „${original.verwendungszweck.slice(0, 200)}": ${input.grund}`,
belegnummer: original.belegnummer,
personId: original.personId,
lieferant: original.lieferant,
storniertVonId: original.id,
erfasstVonId: ctx.sitzung.personId,
},
select: listenauswahl,
});
// Auch am Original vermerken. Das ist die einzige Änderung, die der
// Trigger an einer festgeschriebenen Buchung zulässt — sie ist keine
// Änderung des Vorgangs, sondern seine Fortschreibung.
await db.buchung.update({
where: { id: original.id },
data: { storniertVonId: gegenbuchung.id },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchung",
entitaetId: gegenbuchung.id,
beschreibung:
`Gegenbuchung über ${alsText(original.betrag)} zu „${original.verwendungszweck}": ` +
input.grund,
nachher: { original: original.id, grund: input.grund },
});
return gegenbuchung;
}),
),
// ---------------------------------------------------------------------------
// Bankimport
// ---------------------------------------------------------------------------
/**
* Einen Kontoauszug einlesen.
*
* Herauskommen **Vorschläge**, keine Buchungen — mitsamt dem Hinweis,
* welche davon schon in der Kasse stehen. Diese Erkennung ist der eigentliche
* Wert der Funktion: ein zweimal eingelesener Auszug wäre sonst ein doppelt
* gebuchter Monat, und das fällt erst beim Kassenabschluss auf.
*/
auszugLesen: mitRecht("kasse.bearbeiten")
.input(
z.object({
kontoId: z.string().uuid(),
inhalt: z.string().min(10).max(10_000_000),
}),
)
.mutation(async ({ ctx, input }) => {
const auszug = auszugLesen(input.inhalt);
if (auszug.posten.length === 0) {
return { ...auszug, posten: [], bekannt: 0 };
}
// Was davon steht schon in der Kasse? Verglichen wird über Datum und
// Betrag im Fenster des Auszugs — die Kennung der Bank steht an einer
// vorhandenen Buchung nicht, weil sie auch von Hand erfasst worden sein
// kann.
const daten = auszug.posten.map((posten) => posten.datum.getTime());
const von = new Date(Math.min(...daten));
const bis = new Date(Math.max(...daten));
const vorhandene = await ctx.db((db) =>
db.buchung.findMany({
where: { kontoId: input.kontoId, datum: { gte: von, lte: bis } },
select: { datum: true, betrag: true, art: true, verwendungszweck: true },
}),
);
const posten = auszug.posten.map((posten) => {
const schonDa = vorhandene.some(
(buchung) =>
buchung.datum.toISOString().slice(0, 10) ===
posten.datum.toISOString().slice(0, 10) &&
buchung.art === posten.art &&
buchung.betrag.equals(posten.betrag),
);
return {
datum: posten.datum,
art: posten.art,
betrag: posten.betrag.toFixed(2),
verwendungszweck: posten.verwendungszweck,
gegenseite: posten.gegenseite,
gegenIban: posten.gegenIban,
kennung: posten.kennung,
schonDa,
};
});
return {
format: auszug.format,
konto: auszug.konto,
fehler: auszug.fehler,
posten,
bekannt: posten.filter((eintrag) => eintrag.schonDa).length,
};
}),
/**
* Ausgewählte Posten eines Auszugs übernehmen.
*
* Als Entwürfe: nicht festgeschrieben, damit jemand sie noch einordnen kann.
* Die Kategorie kommt aus dem Vorschlag — falsch zugeordnet ist besser als
* gar nicht zugeordnet, solange es jemand sieht und ändern kann.
*/
auszugUebernehmen: mitRecht("kasse.bearbeiten")
.input(
z.object({
kontoId: z.string().uuid(),
posten: z
.array(
z.object({
datum: z.date(),
art: z.nativeEnum(BuchungsArt),
betrag: z.number().positive(),
verwendungszweck: z.string().trim().min(1).max(300),
gegenseite: z.string().trim().max(200).nullish(),
kategorieId: z.string().uuid(),
}),
)
.min(1)
.max(500),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const ergebnis = await db.buchung.createMany({
data: input.posten.map((posten) => ({
organisationId: ctx.sitzung.organisationId,
kontoId: input.kontoId,
kategorieId: posten.kategorieId,
datum: posten.datum,
art: posten.art,
betrag: posten.betrag,
verwendungszweck: posten.verwendungszweck,
lieferant: posten.gegenseite ?? null,
erfasstVonId: ctx.sitzung.personId,
})),
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchung",
beschreibung: `${ergebnis.count} Buchungen aus einem Kontoauszug übernommen`,
nachher: { anzahl: ergebnis.count, kontoId: input.kontoId },
});
return { uebernommen: ergebnis.count };
}),
),
/**
* Aus einem gegengezeichneten Beleg eine Buchung machen.
*
* Die Bruecke zwischen Phase 8 und Phase 9. Fotografiert und ausgefuellt
* wird der Beleg am Terminal oder am Handy — gebucht wird er hier, mit
* Konto und Kategorie, die eine Maschine nicht entscheiden kann.
*
* Das Foto haengt an der Buchung. Genau das verlangt eine Kassenpruefung:
* zu jeder Ausgabe ein Beleg, und zwar an der Buchung und nicht in einem
* Ordner daneben.
*
* Nur gegengezeichnete Belege: ein Entwurf ist ein von einem Modell
* ausgefuelltes Formular, und daraus wird keine Buchung, bevor ein Mensch
* ihn angesehen hat.
*/
belegBuchen: mitRecht("kasse.bearbeiten")
.input(
z.object({
belegId: z.string().uuid(),
kontoId: z.string().uuid(),
kategorieId: z.string().uuid(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const beleg = await db.belegentwurf.findUnique({
where: { id: input.belegId },
include: { fotoDatei: { select: { id: true } } },
});
if (!beleg || beleg.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Beleg gibt es nicht." });
}
if (beleg.stand !== KiStand.UEBERNOMMEN) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Dieser Beleg ist noch nicht gegengezeichnet. Erst ansehen, dann buchen — " +
"ein Entwurf ist ein von einer Maschine ausgefülltes Formular.",
});
}
if (beleg.buchungId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieser Beleg ist bereits gebucht.",
});
}
if (!beleg.betrag || beleg.betrag.lessThanOrEqualTo(0)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ohne Betrag lässt sich der Beleg nicht buchen.",
});
}
const buchung = await db.buchung.create({
data: {
organisationId: ctx.sitzung.organisationId,
kontoId: input.kontoId,
kategorieId: input.kategorieId,
// Das Belegdatum, nicht der Tag des Fotografierens. Danach
// richtet sich, in welchen Abschluss die Buchung fällt.
datum: beleg.belegdatum ?? new Date(),
art: BuchungsArt.AUSGABE,
betrag: beleg.betrag,
verwendungszweck: beleg.haendler
? `${beleg.haendler}${beleg.kategorie ? `, ${beleg.kategorie}` : ""}`
: (beleg.kategorie ?? "Beleg"),
lieferant: beleg.haendler ?? null,
belegDateiId: beleg.fotoDateiId,
erfasstVonId: ctx.sitzung.personId,
},
});
await db.belegentwurf.update({
where: { id: input.belegId },
data: { buchungId: buchung.id },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchung",
entitaetId: buchung.id,
beschreibung: `Beleg über ${alsText(beleg.betrag)} gebucht`,
nachher: { belegId: input.belegId, betrag: beleg.betrag.toFixed(2) },
});
return buchung;
}),
),
});
+399
View File
@@ -0,0 +1,399 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BuchungsArt, KontoArt } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { bestand } from "@/lib/kasse/betrag";
import { STANDARDKATEGORIEN } from "@/lib/kasse/vorschlag";
/**
* Konten und Kategorien — das Gerüst der Kasse.
*
* Eine Wehr führt in aller Regel drei Konten: die Barkasse im Gerätehaus, ein
* Bankkonto und eine Rücklage für die nächste größere Anschaffung. Die
* Trennung der Rücklage ist keine Buchhaltungsspielerei — sie ist der Grund,
* warum das Geld für das neue Fahrzeug in fünf Jahren noch da ist.
*
* **Der Bestand wird gerechnet, nicht 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.
*/
const kontoFelder = z.object({
bezeichnung: z.string().trim().min(1).max(120),
art: z.nativeEnum(KontoArt).default(KontoArt.BAR),
iban: z.string().trim().max(34).nullish(),
bic: z.string().trim().max(11).nullish(),
anfangsbestand: z.number().min(-1_000_000).max(1_000_000).default(0),
bemerkung: z.string().trim().max(500).nullish(),
aktiv: z.boolean().default(true),
});
const kategorieFelder = z.object({
name: z.string().trim().min(1).max(120),
art: z.nativeEnum(BuchungsArt),
oberkategorieId: z.string().uuid().nullish(),
kontonummer: z.string().trim().max(20).nullish(),
bemerkung: z.string().trim().max(500).nullish(),
aktiv: z.boolean().default(true),
});
export const kassenkontoRouter = router({
/**
* Die Konten mit ihrem Bestand.
*
* Der Bestand kommt aus den Buchungen. Bei einer Wehr mit zweihundert
* Buchungen im Jahr ist das eine Summenbildung über ein paar tausend Zeilen —
* das rechnet die Datenbank schneller, als sie einen gespeicherten Wert
* suchen würde.
*/
liste: mitRecht("kasse.lesen")
.input(z.object({ mitInaktiven: z.boolean().default(false) }).optional())
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const konten = await db.kassenkonto.findMany({
where: { geloeschtAm: null, ...(input?.mitInaktiven ? {} : { aktiv: true }) },
orderBy: [{ art: "asc" }, { bezeichnung: "asc" }],
});
const summen = await db.buchung.groupBy({
by: ["kontoId", "art"],
_sum: { betrag: true },
});
return konten.map((konto) => {
const eigene = summen
.filter((zeile) => zeile.kontoId === konto.id)
.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 }));
return {
...konto,
bestand: bestand(konto.anfangsbestand, eigene).toFixed(2),
};
});
}),
),
einzeln: mitRecht("kasse.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const konto = await ctx.db(async (db) => {
const eintrag = await db.kassenkonto.findUnique({
where: { id: input.id },
include: { _count: { select: { buchungen: true } } },
});
if (!eintrag || eintrag.geloeschtAm) return null;
const summen = await db.buchung.groupBy({
by: ["art"],
where: { kontoId: input.id },
_sum: { betrag: true },
});
return {
...eintrag,
bestand: bestand(
eintrag.anfangsbestand,
summen.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
).toFixed(2),
};
});
if (!konto) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Konto gibt es nicht." });
}
return konto;
}),
anlegen: mitRecht("kasse.bearbeiten")
.input(kontoFelder)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const konto = await db.kassenkonto.create({
data: { ...input, organisationId: ctx.sitzung.organisationId },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Kassenkonto",
entitaetId: konto.id,
beschreibung: `Konto „${input.bezeichnung}" angelegt`,
nachher: { ...input },
});
return konto;
}),
),
aendern: mitRecht("kasse.bearbeiten")
.input(kontoFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const { id, ...felder } = input;
return ctx.db(async (db) => {
const vorher = await db.kassenkonto.findUnique({
where: { id },
include: { _count: { select: { buchungen: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Konto gibt es nicht." });
}
// Der Anfangsbestand ist der Ausgangspunkt aller Bestände. Ihn zu
// ändern, wenn schon gebucht wurde, verschöbe jeden Kontostand
// rückwirkend — auch die bereits geprüften.
if (
felder.anfangsbestand !== undefined &&
!vorher.anfangsbestand.equals(felder.anfangsbestand) &&
vorher._count.buchungen > 0
) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Auf diesem Konto stehen bereits ${vorher._count.buchungen} Buchungen. ` +
"Der Anfangsbestand lässt sich dann nicht mehr ändern — eine Korrektur " +
"läuft über eine Buchung, damit sie nachvollziehbar bleibt.",
});
}
const nachher = await db.kassenkonto.update({ where: { id }, data: felder });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Kassenkonto",
entitaetId: id,
beschreibung: `Konto „${nachher.bezeichnung}" geändert`,
vorher: vorher as unknown as Record<string, unknown>,
nachher: nachher as unknown as Record<string, unknown>,
});
return nachher;
});
}),
/**
* Ein Konto stilllegen.
*
* Nie löschen: an ihm hängen Buchungen, und ein Kassenbuch mit Zeilen ohne
* Konto ist keines. Stillgelegt heißt: taucht in der Buchungsmaske nicht
* mehr auf, in Auswertungen und Abschlüssen weiterhin.
*/
stilllegen: mitRecht("kasse.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const konto = await db.kassenkonto.findUnique({
where: { id: input.id },
select: { bezeichnung: true },
});
if (!konto) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Konto gibt es nicht." });
}
await db.kassenkonto.update({ where: { id: input.id }, data: { aktiv: false } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Kassenkonto",
entitaetId: input.id,
beschreibung: `Konto „${konto.bezeichnung}" stillgelegt`,
vorher: { aktiv: true },
nachher: { aktiv: false },
});
return { erfolgreich: true };
}),
),
});
export const buchungskategorieRouter = router({
liste: mitRecht("kasse.lesen")
.input(
z
.object({
art: z.nativeEnum(BuchungsArt).optional(),
mitInaktiven: z.boolean().default(false),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.buchungskategorie.findMany({
where: {
geloeschtAm: null,
...(input?.art ? { art: input.art } : {}),
...(input?.mitInaktiven ? {} : { aktiv: true }),
},
orderBy: [{ art: "asc" }, { name: "asc" }],
include: {
oberkategorie: { select: { id: true, name: true } },
_count: { select: { buchungen: true } },
},
}),
),
),
anlegen: mitRecht("kasse.bearbeiten")
.input(kategorieFelder)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
if (input.oberkategorieId) {
const ober = await db.buchungskategorie.findUnique({
where: { id: input.oberkategorieId },
select: { art: true, oberkategorieId: true },
});
if (!ober) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Oberkategorie gibt es nicht." });
}
if (ober.art !== input.art) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Eine Ausgabe kann nicht unter einer Einnahmekategorie stehen.",
});
}
// Zwei Ebenen reichen. Jede weitere macht die Auswertung unlesbar,
// und niemand findet mehr, wo eine Buchung eingeordnet ist.
if (ober.oberkategorieId) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Es gibt genau zwei Ebenen: Oberkategorie und Kategorie. Eine dritte " +
"würde die Auswertung unübersichtlich machen.",
});
}
}
const kategorie = await db.buchungskategorie.create({
data: { ...input, organisationId: ctx.sitzung.organisationId },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchungskategorie",
entitaetId: kategorie.id,
beschreibung: `Kategorie „${input.name}" angelegt`,
nachher: { ...input },
});
return kategorie;
}),
),
aendern: mitRecht("kasse.bearbeiten")
.input(kategorieFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const { id, ...felder } = input;
return ctx.db(async (db) => {
const vorher = await db.buchungskategorie.findUnique({
where: { id },
include: { _count: { select: { buchungen: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Kategorie gibt es nicht." });
}
// Die Richtung einer Kategorie zu drehen, würde jede vorhandene
// Buchung umdeuten — aus einer Ausgabe würde eine Einnahme, und die
// Jahresauswertung stimmte rückwirkend nicht mehr.
if (felder.art && felder.art !== vorher.art && vorher._count.buchungen > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Auf diese Kategorie sind ${vorher._count.buchungen} Buchungen gebucht. ` +
"Einnahme und Ausgabe lassen sich dann nicht mehr tauschen — bitte eine " +
"neue Kategorie anlegen und die alte stilllegen.",
});
}
const nachher = await db.buchungskategorie.update({ where: { id }, data: felder });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Buchungskategorie",
entitaetId: id,
beschreibung: `Kategorie „${nachher.name}" geändert`,
vorher: vorher as unknown as Record<string, unknown>,
nachher: nachher as unknown as Record<string, unknown>,
});
return nachher;
});
}),
/**
* Den Standardkontenrahmen anlegen.
*
* Eine Wehr, die mit der Kasse anfängt, soll nicht erst zwanzig Kategorien
* eintippen. Die Namen entsprechen denen, auf die sich der Kategorievorschlag
* bezieht — deshalb stehen beide in derselben Datei.
*/
standardAnlegen: mitRecht("kasse.bearbeiten").mutation(async ({ ctx }) =>
ctx.db(async (db) => {
const vorhanden = await db.buchungskategorie.count({ where: { geloeschtAm: null } });
if (vorhanden > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Es gibt bereits ${vorhanden} Kategorien. Der Kontenrahmen wird nur in eine ` +
"leere Kasse gelegt — sonst stünde alles doppelt da.",
});
}
// Erst die Oberkategorien, damit die Unterkategorien sie finden.
const nachName = new Map<string, string>();
for (const eintrag of STANDARDKATEGORIEN.filter((k) => !k.oberkategorie)) {
const angelegt = await db.buchungskategorie.create({
data: {
organisationId: ctx.sitzung.organisationId,
name: eintrag.name,
art: eintrag.art,
},
select: { id: true, name: true },
});
nachName.set(angelegt.name, angelegt.id);
}
for (const eintrag of STANDARDKATEGORIEN.filter((k) => k.oberkategorie)) {
await db.buchungskategorie.create({
data: {
organisationId: ctx.sitzung.organisationId,
name: eintrag.name,
art: eintrag.art,
oberkategorieId: nachName.get(eintrag.oberkategorie!) ?? null,
},
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchungskategorie",
beschreibung: `Standardkontenrahmen mit ${STANDARDKATEGORIEN.length} Kategorien angelegt`,
});
return { angelegt: STANDARDKATEGORIEN.length };
}),
),
});
+538
View File
@@ -0,0 +1,538 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BuchungsArt, Prisma, RechnungStand, RechnungsAnlass } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { naechsteNummer } from "@/lib/nummernkreis";
import { alsText, positionssumme, summe } from "@/lib/kasse/betrag";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
/**
* Rechnungen an Dritte.
*
* Fehlalarm, Ölspur, Absicherung einer Veranstaltung, Vermietung. Also das,
* was eine Wehr nach dem Verursacherprinzip oder aus einer Vereinbarung heraus
* abrechnet.
*
* Zwei Festlegungen:
*
* * **Der Betrag wird gerechnet, nie eingegeben.** Summe der Positionen,
* jede davon Menge mal Einzelpreis. Ein von Hand eingetragener Gesamtbetrag
* neben Positionen, die etwas anderes ergeben, ist der Fehler, den niemand
* bemerkt.
* * **Eine versendete Rechnung wird nicht mehr geändert.** Was beim
* Empfänger liegt, gilt. Korrekturen laufen über eine Stornierung und eine
* neue Rechnung — genau wie bei den Buchungen.
*
* Die Nummer kommt aus demselben Nummernkreis wie Einsätze und Prüfungen:
* `JJJJ-NNNN`, fortlaufend im Jahr, je Wehr getrennt.
*/
const positionFelder = z.object({
bezeichnung: z.string().trim().min(1).max(300),
menge: z.number().positive().max(100_000).default(1),
einheit: z.string().trim().max(20).nullish(),
einzelpreis: z.number().min(0).max(1_000_000),
});
const rechnungFelder = z.object({
anlass: z.nativeEnum(RechnungsAnlass).default(RechnungsAnlass.SONSTIGES),
empfaenger: z.string().trim().min(1).max(200),
empfaengerZusatz: z.string().trim().max(200).nullish(),
strasse: z.string().trim().max(200).nullish(),
plz: z.string().trim().max(10).nullish(),
ort: z.string().trim().max(120).nullish(),
einsatzId: z.string().uuid().nullish(),
datum: z.date(),
faelligAm: z.date(),
bemerkung: z.string().trim().max(1000).nullish(),
});
export const rechnungRouter = router({
liste: mitRecht("kasse.lesen")
.input(
z
.object({
stand: z.nativeEnum(RechnungStand).optional(),
jahr: z.number().int().optional(),
suche: z.string().trim().max(200).optional(),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const jahresgrenzen = input?.jahr
? {
gte: new Date(Date.UTC(input.jahr, 0, 1)),
lte: new Date(Date.UTC(input.jahr, 11, 31)),
}
: undefined;
const rechnungen = await db.rechnung.findMany({
where: {
...(input?.stand ? { stand: input.stand } : {}),
...(jahresgrenzen ? { datum: jahresgrenzen } : {}),
...(input?.suche
? {
OR: [
{ empfaenger: { contains: input.suche, mode: "insensitive" } },
{ nummer: { contains: input.suche, mode: "insensitive" } },
],
}
: {}),
},
orderBy: { datum: "desc" },
take: 300,
include: { _count: { select: { positionen: true } } },
});
const offen = rechnungen.filter(
(eintrag) =>
eintrag.stand === RechnungStand.VERSENDET || eintrag.stand === RechnungStand.GEMAHNT,
);
const heute = new Date();
return {
rechnungen,
kennzahlen: {
gesamt: summe(rechnungen.map((eintrag) => eintrag.betrag)).toFixed(2),
offen: summe(offen.map((eintrag) => eintrag.betrag)).toFixed(2),
anzahlOffen: offen.length,
ueberfaellig: offen.filter((eintrag) => eintrag.faelligAm < heute).length,
},
};
}),
),
einzeln: mitRecht("kasse.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const rechnung = await ctx.db((db) =>
db.rechnung.findUnique({
where: { id: input.id },
include: {
positionen: { orderBy: { reihenfolge: "asc" } },
buchung: { select: { id: true, datum: true, betrag: true } },
pdfDatei: { select: { id: true, dateiname: true } },
erstelltVon: { select: { vorname: true, nachname: true } },
},
}),
);
if (!rechnung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Rechnung gibt es nicht." });
}
return rechnung;
}),
anlegen: mitRecht("kasse.bearbeiten")
.input(rechnungFelder.extend({ positionen: z.array(positionFelder).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
const { positionen, ...felder } = input;
if (felder.faelligAm < felder.datum) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Eine Rechnung kann nicht fällig werden, bevor sie geschrieben wurde.",
});
}
return ctx.db(async (db) => {
const jahr = felder.datum.getFullYear();
// Der Gesamtbetrag ist die Summe der Positionen — gerechnet, nicht
// eingegeben. Ein eingetragener Betrag neben Positionen, die etwas
// anderes ergeben, ist der Fehler, den niemand bemerkt.
const gerechnet = positionen.map((position, stelle) => ({
reihenfolge: stelle,
bezeichnung: position.bezeichnung,
menge: position.menge,
einheit: position.einheit ?? null,
einzelpreis: position.einzelpreis,
gesamtpreis: positionssumme(position.menge, position.einzelpreis),
}));
const betrag = summe(gerechnet.map((position) => position.gesamtpreis));
/**
* Die Nummer vergeben und schreiben — mit Wiederholung.
*
* Höchste lesen und dann schreiben ist ein Wettlauf: schreiben zwei
* Leute gleichzeitig eine Rechnung, lesen beide dieselbe höchste
* Nummer, und die zweite läuft in den Eindeutigkeitsfehler. Statt
* die Vergabe zu sperren — was jede Rechnung hinter jede andere
* stellte — wird der seltene Zusammenstoß erkannt und die nächste
* Nummer genommen. Lückenlos bleibt sie dabei, und genau darauf
* kommt es in der Kassenprüfung an.
*/
let rechnung = null as Awaited<ReturnType<typeof anlegenMit>> | null;
let nummer = "";
async function anlegenMit(vergebene: string) {
return db.rechnung.create({
data: {
...felder,
organisationId: ctx.sitzung.organisationId,
nummer: vergebene,
betrag,
erstelltVonId: ctx.sitzung.personId,
positionen: {
create: gerechnet.map((position) => ({
...position,
organisationId: ctx.sitzung.organisationId,
})),
},
},
include: { positionen: { orderBy: { reihenfolge: "asc" } } },
});
}
for (let versuch = 0; versuch < 5 && !rechnung; versuch += 1) {
const hoechste = await db.rechnung.findFirst({
where: { nummer: { startsWith: `${jahr}-` } },
orderBy: { nummer: "desc" },
select: { nummer: true },
});
nummer = naechsteNummer(jahr, hoechste?.nummer);
try {
rechnung = await anlegenMit(nummer);
} catch (fehler) {
const belegt =
fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002";
if (!belegt || versuch === 4) throw fehler;
}
}
if (!rechnung) {
throw new TRPCError({
code: "CONFLICT",
message:
"Die Rechnungsnummer war mehrfach hintereinander schon vergeben. " +
"Bitte noch einmal versuchen.",
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Rechnung",
entitaetId: rechnung.id,
beschreibung: `Rechnung ${nummer} an ${felder.empfaenger} über ${alsText(betrag)}`,
nachher: { nummer, empfaenger: felder.empfaenger, betrag: betrag.toFixed(2) },
});
return rechnung;
});
}),
aendern: mitRecht("kasse.bearbeiten")
.input(
rechnungFelder
.partial()
.extend({
id: z.string().uuid(),
positionen: z.array(positionFelder).min(1).max(100).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, positionen, ...felder } = input;
return ctx.db(async (db) => {
const vorher = await db.rechnung.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Rechnung gibt es nicht." });
}
if (vorher.stand !== RechnungStand.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Rechnung ist bereits versendet. Was beim Empfänger liegt, gilt — für " +
"eine Korrektur bitte stornieren und neu schreiben.",
});
}
let betrag = vorher.betrag;
if (positionen) {
const gerechnet = positionen.map((position, stelle) => ({
reihenfolge: stelle,
bezeichnung: position.bezeichnung,
menge: position.menge,
einheit: position.einheit ?? null,
einzelpreis: position.einzelpreis,
gesamtpreis: positionssumme(position.menge, position.einzelpreis),
organisationId: ctx.sitzung.organisationId,
}));
betrag = summe(gerechnet.map((position) => position.gesamtpreis));
await db.rechnungsposition.deleteMany({ where: { rechnungId: id } });
await db.rechnungsposition.createMany({
data: gerechnet.map((position) => ({ ...position, rechnungId: id })),
});
}
const nachher = await db.rechnung.update({
where: { id },
data: { ...felder, betrag },
include: { positionen: { orderBy: { reihenfolge: "asc" } } },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rechnung",
entitaetId: id,
beschreibung: `Rechnung ${nachher.nummer} geändert`,
vorher: { betrag: vorher.betrag.toFixed(2), empfaenger: vorher.empfaenger },
nachher: { betrag: nachher.betrag.toFixed(2), empfaenger: nachher.empfaenger },
});
return nachher;
});
}),
/**
* Versenden.
*
* Der Schritt, nach dem nichts mehr geändert wird. Das PDF entsteht im
* Hintergrund — es ist der Stand, der beim Empfänger liegt.
*/
versenden: mitRecht("kasse.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) => {
const rechnung = await ctx.db(async (db) => {
const vorher = await db.rechnung.findUnique({
where: { id: input.id },
include: { _count: { select: { positionen: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Rechnung gibt es nicht." });
}
if (vorher.stand !== RechnungStand.ENTWURF) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Diese Rechnung ist bereits versendet." });
}
if (vorher._count.positionen === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Eine Rechnung ohne Positionen ist keine.",
});
}
const nachher = await db.rechnung.update({
where: { id: input.id },
data: { stand: RechnungStand.VERSENDET },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rechnung",
entitaetId: input.id,
beschreibung: `Rechnung ${nachher.nummer} versendet`,
vorher: { stand: RechnungStand.ENTWURF },
nachher: { stand: RechnungStand.VERSENDET },
});
return nachher;
});
await beauftrage(AUFTRAG.RECHNUNG_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
rechnungId: rechnung.id,
}).catch((fehler) => {
console.error("[rechnung] PDF-Auftrag ließ sich nicht einstellen:", fehler);
});
return rechnung;
}),
/**
* Zahlungseingang.
*
* Erzeugt die Buchung — genau wie beim Beitrag. „Bezahlt" ohne Geld in der
* Kasse wäre eine Behauptung.
*/
bezahlen: mitRecht("kasse.bearbeiten")
.input(
z.object({
id: z.string().uuid(),
kontoId: z.string().uuid(),
kategorieId: z.string().uuid(),
bezahltAm: z.date(),
betrag: z.number().positive().max(1_000_000).optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rechnung = await db.rechnung.findUnique({ where: { id: input.id } });
if (!rechnung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Rechnung gibt es nicht." });
}
if (rechnung.stand === RechnungStand.BEZAHLT) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Diese Rechnung ist bezahlt." });
}
if (rechnung.stand === RechnungStand.STORNIERT) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Diese Rechnung ist storniert — eine Zahlung darauf gehört gegengebucht.",
});
}
const betragWert = input.betrag ?? Number(rechnung.betrag);
const buchung = await db.buchung.create({
data: {
organisationId: ctx.sitzung.organisationId,
kontoId: input.kontoId,
kategorieId: input.kategorieId,
datum: input.bezahltAm,
art: BuchungsArt.EINNAHME,
betrag: betragWert,
verwendungszweck: `Rechnung ${rechnung.nummer} — ${rechnung.empfaenger}`,
belegnummer: rechnung.nummer,
lieferant: rechnung.empfaenger,
erfasstVonId: ctx.sitzung.personId,
},
select: { id: true },
});
const nachher = await db.rechnung.update({
where: { id: input.id },
data: { stand: RechnungStand.BEZAHLT, bezahltAm: input.bezahltAm, buchungId: buchung.id },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rechnung",
entitaetId: input.id,
beschreibung: `Rechnung ${rechnung.nummer} bezahlt (${alsText(betragWert)})`,
vorher: { stand: rechnung.stand },
nachher: { stand: RechnungStand.BEZAHLT, buchungId: buchung.id },
});
return nachher;
}),
),
mahnen: mitRecht("kasse.bearbeiten")
.input(z.object({ ids: z.array(z.string().uuid()).min(1).max(200) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const betroffen = await db.rechnung.findMany({
where: {
id: { in: input.ids },
stand: { in: [RechnungStand.VERSENDET, RechnungStand.GEMAHNT] },
},
select: { id: true, mahnstufe: true, nummer: true },
});
const jetzt = new Date();
let gemahnt = 0;
for (const eintrag of betroffen) {
if (eintrag.mahnstufe >= 3) continue;
await db.rechnung.update({
where: { id: eintrag.id },
data: {
stand: RechnungStand.GEMAHNT,
mahnstufe: eintrag.mahnstufe + 1,
gemahntAm: jetzt,
},
});
gemahnt += 1;
}
if (gemahnt > 0) {
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rechnung",
beschreibung: `${gemahnt} Rechnungen gemahnt`,
nachher: { anzahl: gemahnt, gemahntAm: jetzt },
});
}
return { gemahnt, uebersprungen: betroffen.length - gemahnt };
}),
),
/**
* Stornieren.
*
* Die Rechnung bleibt stehen — durchgestrichen, mit Grund. Eine
* verschwundene Rechnungsnummer ist in der Kassenprüfung eine Lücke, die
* erklärt werden muss.
*/
stornieren: mitRecht("kasse.bearbeiten")
.input(z.object({ id: z.string().uuid(), grund: z.string().trim().min(3).max(500) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rechnung = await db.rechnung.findUnique({ where: { id: input.id } });
if (!rechnung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Rechnung gibt es nicht." });
}
if (rechnung.stand === RechnungStand.BEZAHLT) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Rechnung ist bezahlt. Für eine Rückzahlung bitte die Buchung gegenbuchen " +
"und die Rechnung danach stornieren.",
});
}
const nachher = await db.rechnung.update({
where: { id: input.id },
data: {
stand: RechnungStand.STORNIERT,
bemerkung: `${rechnung.bemerkung ? `${rechnung.bemerkung}\n\n` : ""}Storniert: ${input.grund}`,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rechnung",
entitaetId: input.id,
beschreibung: `Rechnung ${rechnung.nummer} storniert: ${input.grund}`,
vorher: { stand: rechnung.stand },
nachher: { stand: RechnungStand.STORNIERT, grund: input.grund },
});
return nachher;
}),
),
/** Das PDF neu erzeugen — nach einer Änderung an den Wehrdaten etwa. */
pdfErzeugen: mitRecht("kasse.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) => {
await beauftrage(AUFTRAG.RECHNUNG_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
rechnungId: input.id,
});
return {
erfolgreich: true,
hinweis: "Die Rechnung wird im Hintergrund erzeugt und steht gleich zum Herunterladen.",
};
}),
});
+45
View File
@@ -22,6 +22,7 @@ import {
zustellen,
} from "@/worker/benachrichtigung";
import { diktatErkennen, veralteteSeitenEinbetten, wikiEinbetten } from "@/worker/ki";
import { pruefberichtErzeugen, rechnungErzeugen, ueberfaelligeMelden } from "@/worker/kasse";
/**
* Hintergrundprozess.
@@ -195,6 +196,40 @@ async function diktatErkennenAuftrag(auftrag: Job<AuftragsDaten>) {
return diktatErkennen(organisationId, diktatId);
}
/** Eine Rechnung als PDF erzeugen (Phase 9). */
async function rechnungErzeugenAuftrag(auftrag: Job<AuftragsDaten>) {
const { organisationId, rechnungId } = auftrag.data as AuftragsDaten & { rechnungId?: string };
if (!rechnungId) {
throw new Error("Rechnungsauftrag ohne Angabe, wofür — rechnungId fehlt.");
}
const ergebnis = await rechnungErzeugen(organisationId, rechnungId);
if (!ergebnis.erzeugt) {
protokoll(`Keine Rechnung für ${rechnungId} — unbekannt oder ohne Wehrdaten.`);
}
return ergebnis;
}
/** Den Kassenprüfbericht als PDF erzeugen (Phase 9). */
async function kassenpruefberichtAuftrag(auftrag: Job<AuftragsDaten>) {
const { organisationId, abschlussId } = auftrag.data as AuftragsDaten & { abschlussId?: string };
if (!abschlussId) {
throw new Error("Berichtsauftrag ohne Angabe, wofür — abschlussId fehlt.");
}
const ergebnis = await pruefberichtErzeugen(organisationId, abschlussId);
if (!ergebnis.erzeugt) {
protokoll(`Kein Kassenprüfbericht für ${abschlussId} — unbekannt.`);
}
return ergebnis;
}
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
@@ -209,6 +244,14 @@ async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfe
console.error("[worker] Nachträgliches Einbetten fehlgeschlagen:", fehler);
});
// Seit Phase 9: überfällige Rechnungen zählen. Bewusst nur zählen und ins
// Protokoll schreiben — eine Software, die ungefragt mahnt, verschickt
// irgendwann eine Mahnung an den Bürgermeister, weil ein Zahlungseingang
// zwei Tage später verbucht wurde.
await ueberfaelligeMelden(auftrag.data.organisationId).catch((fehler) => {
console.error("[worker] Fälligkeitsprüfung fehlgeschlagen:", fehler);
});
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
// ausgetretene Mitglieder nach PERSON_ANONYMISIERUNG_TAGE anonymisieren.
return { entfernt: benachrichtigungen.entfernt };
@@ -224,6 +267,8 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
[AUFTRAG.BENACHRICHTIGUNG_ERINNERN]: benachrichtigungErinnern,
[AUFTRAG.WIKI_EINBETTEN]: wikiEinbettenAuftrag,
[AUFTRAG.DIKTAT_ERKENNEN]: diktatErkennenAuftrag,
[AUFTRAG.RECHNUNG_ERZEUGEN]: rechnungErzeugenAuftrag,
[AUFTRAG.KASSENPRUEFBERICHT_ERZEUGEN]: kassenpruefberichtAuftrag,
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
};
+305
View File
@@ -0,0 +1,305 @@
import { BuchungsArt, KontoArt } from "@prisma/client";
import { fuerOrganisation } from "@/lib/db";
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { hochladen, objektSchluessel } from "@/lib/storage";
import { bilanz } from "@/lib/kasse/betrag";
import { pruefberichtPdf, rechnungPdf } from "@/lib/kasse/dokumente";
/**
* Die beiden Papiere der Kasse erzeugen.
*
* Im Hintergrund, weil Gotenberg ein paar Sekunden braucht — wer gerade eine
* Rechnung versendet hat, soll dabei nicht warten.
*
* Der Ablauf ist wie bei den Prüfprotokollen aus Phase 2 dreigeteilt: lesen,
* rendern, schreiben. Zwischen Lesen und Schreiben liegt ein Netzaufruf, und
* eine offene Datenbanktransaktion dürfte darauf nicht warten — sie liefe in
* ihr Zeitlimit und gäbe die Verbindung nicht frei.
*
* Beide Vorgänge sind wiederholbar. Ein zweiter Lauf erzeugt das PDF neu und
* ersetzt den Verweis; das ist gewollt, denn nach einer Änderung an den
* Wehrdaten soll sich eine Rechnung neu erzeugen lassen.
*/
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
console.info(`[kasse] ${nachricht}`, ...weiteres);
}
/** Eine Rechnung als PDF. */
export async function rechnungErzeugen(
organisationId: string,
rechnungId: string,
): Promise<{ erzeugt: boolean; dateiId?: string }> {
// --- lesen ----------------------------------------------------------------
const daten = await fuerOrganisation(organisationId, async (db) => {
const rechnung = await db.rechnung.findUnique({
where: { id: rechnungId },
include: { positionen: { orderBy: { reihenfolge: "asc" } } },
});
if (!rechnung) return null;
const organisation = await db.organisation.findUnique({
where: { id: organisationId },
select: {
slug: true,
name: true,
strasse: true,
plz: true,
ort: true,
telefon: true,
email: true,
},
});
// Das erste aktive Bankkonto für den Zahlungshinweis. Eine Rechnung ohne
// Kontoverbindung wird nicht bezahlt — sie wird zurückgerufen.
const bankkonto = await db.kassenkonto.findFirst({
where: { art: KontoArt.BANK, aktiv: true, geloeschtAm: null, iban: { not: null } },
select: { bezeichnung: true, iban: true },
orderBy: { erstelltAm: "asc" },
});
return { rechnung, organisation, bankkonto };
});
if (!daten?.rechnung || !daten.organisation) {
return { erzeugt: false };
}
const { rechnung, organisation, bankkonto } = daten;
// --- rendern --------------------------------------------------------------
const pdf = await rechnungPdf({
wehr: organisation,
nummer: rechnung.nummer,
anlass: rechnung.anlass,
datum: rechnung.datum,
faelligAm: rechnung.faelligAm,
empfaenger: rechnung.empfaenger,
empfaengerZusatz: rechnung.empfaengerZusatz,
strasse: rechnung.strasse,
plz: rechnung.plz,
ort: rechnung.ort,
bemerkung: rechnung.bemerkung,
betrag: rechnung.betrag.toFixed(2),
positionen: rechnung.positionen.map((position) => ({
bezeichnung: position.bezeichnung,
menge: position.menge.toFixed(3).replace(/\.?0+$/, "").replace(".", ","),
einheit: position.einheit,
einzelpreis: position.einzelpreis.toFixed(2),
gesamtpreis: position.gesamtpreis.toFixed(2),
})),
bankkonto,
});
// --- schreiben ------------------------------------------------------------
const dateiname = `rechnung-${rechnung.nummer}.pdf`;
const schluessel = objektSchluessel({
organisationSlug: organisation.slug,
entitaet: "Rechnung",
entitaetId: rechnung.id,
dateiname,
});
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
rechnung: rechnung.nummer,
});
const dateiId = await fuerOrganisation(organisationId, async (db) => {
const datei = await db.datei.create({
data: {
organisationId,
bucket: abgelegt.bucket,
objektSchluessel: schluessel,
dateiname,
mimeTyp: "application/pdf",
groesseBytes: abgelegt.groesseBytes,
pruefsumme: abgelegt.pruefsumme,
entitaet: "Rechnung",
entitaetId: rechnung.id,
kategorie: "rechnung",
},
select: { id: true },
});
await db.rechnung.update({ where: { id: rechnung.id }, data: { pdfDateiId: datei.id } });
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Rechnung",
entitaetId: rechnung.id,
beschreibung: `Rechnung ${rechnung.nummer} als PDF erzeugt`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
});
return datei.id;
});
protokoll(`Rechnung ${rechnung.nummer} erzeugt (${abgelegt.groesseBytes} Bytes)`);
return { erzeugt: true, dateiId };
}
/** Der Kassenprüfbericht als PDF. */
export async function pruefberichtErzeugen(
organisationId: string,
abschlussId: string,
): Promise<{ erzeugt: boolean; dateiId?: string }> {
const daten = await fuerOrganisation(organisationId, async (db) => {
const abschluss = await db.kassenabschluss.findUnique({
where: { id: abschlussId },
include: {
pruefer: { select: { vorname: true, nachname: true } },
zweiterPruefer: { select: { vorname: true, nachname: true } },
posten: {
orderBy: { konto: { bezeichnung: "asc" } },
include: { konto: { select: { bezeichnung: true, art: true } } },
},
},
});
if (!abschluss) return null;
const organisation = await db.organisation.findUnique({
where: { id: organisationId },
select: { slug: true, name: true, ort: true },
});
const buchungen = await db.buchung.findMany({
where: { datum: { gte: abschluss.vonAm, lte: abschluss.bisAm } },
select: { art: true, betrag: true },
});
return { abschluss, organisation, buchungen };
});
if (!daten?.abschluss || !daten.organisation) {
return { erzeugt: false };
}
const { abschluss, organisation, buchungen } = daten;
const bewegung = bilanz(buchungen);
const name = (person: { vorname: string; nachname: string } | null) =>
person ? `${person.vorname} ${person.nachname}` : null;
const pdf = await pruefberichtPdf({
wehr: organisation,
vonAm: abschluss.vonAm,
bisAm: abschluss.bisAm,
geprueftAm: abschluss.geprueftAm,
pruefer: name(abschluss.pruefer),
zweiterPruefer: name(abschluss.zweiterPruefer),
bestandLautBuch: abschluss.bestandLautBuch.toFixed(2),
gezaehlterBestand: abschluss.gezaehlterBestand.toFixed(2),
differenz: abschluss.differenz.toFixed(2),
erlaeuterung: abschluss.erlaeuterung,
posten: abschluss.posten.map((posten) => ({
bezeichnung: posten.konto.bezeichnung,
art: posten.konto.art,
bestandLautBuch: posten.bestandLautBuch.toFixed(2),
gezaehlterBestand: posten.gezaehlterBestand.toFixed(2),
differenz: posten.differenz.toFixed(2),
bemerkung: posten.bemerkung,
})),
einnahmen: bewegung.einnahmen.toFixed(2),
ausgaben: bewegung.ausgaben.toFixed(2),
anzahlBuchungen: buchungen.length,
});
const zeitraum = `${abschluss.vonAm.toISOString().slice(0, 10)}-bis-${abschluss.bisAm
.toISOString()
.slice(0, 10)}`;
const dateiname = `kassenpruefbericht-${zeitraum}.pdf`;
const schluessel = objektSchluessel({
organisationSlug: organisation.slug,
entitaet: "Kassenabschluss",
entitaetId: abschluss.id,
dateiname,
});
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
zeitraum,
});
const dateiId = await fuerOrganisation(organisationId, async (db) => {
const datei = await db.datei.create({
data: {
organisationId,
bucket: abgelegt.bucket,
objektSchluessel: schluessel,
dateiname,
mimeTyp: "application/pdf",
groesseBytes: abgelegt.groesseBytes,
pruefsumme: abgelegt.pruefsumme,
entitaet: "Kassenabschluss",
entitaetId: abschluss.id,
kategorie: "kassenpruefbericht",
},
select: { id: true },
});
// Der Verweis auf den Bericht ist die einzige Änderung, die der Trigger an
// einem festgeschriebenen Abschluss zulässt: das PDF entsteht im
// Hintergrund und damit erst danach — es nachzutragen ist keine Änderung
// des Abschlusses, sondern seine Fertigstellung.
await db.kassenabschluss.update({
where: { id: abschluss.id },
data: { berichtDateiId: datei.id },
});
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Kassenabschluss",
entitaetId: abschluss.id,
beschreibung: "Kassenprüfbericht als PDF erzeugt",
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
kontext: { dateiId: datei.id },
});
return datei.id;
});
protokoll(`Kassenprüfbericht ${zeitraum} erzeugt (${abgelegt.groesseBytes} Bytes)`);
return { erzeugt: true, dateiId };
}
/**
* Rechnungen mahnen, die überfällig sind.
*
* Läuft im nächtlichen Lauf mit und **mahnt nicht selbst** — es wird nur
* vermerkt, dass etwas fällig ist. Eine Software, die ungefragt Mahnungen
* verschickt, verschickt irgendwann eine an den Bürgermeister, weil ein
* Zahlungseingang zwei Tage später verbucht wurde.
*/
export async function ueberfaelligeMelden(
organisationId: string,
heute = new Date(),
): Promise<{ rechnungen: number; betrag: string }> {
return fuerOrganisation(organisationId, async (db) => {
const offen = await db.rechnung.findMany({
where: { stand: { in: ["VERSENDET", "GEMAHNT"] }, faelligAm: { lt: heute } },
select: { nummer: true, betrag: true, faelligAm: true, empfaenger: true },
});
if (offen.length === 0) return { rechnungen: 0, betrag: "0.00" };
const summe = offen.reduce(
(bisher, rechnung) => bisher.plus(rechnung.betrag),
offen[0]!.betrag.minus(offen[0]!.betrag),
);
protokoll(`${offen.length} überfällige Rechnungen über ${summe.toFixed(2)} €`);
return { rechnungen: offen.length, betrag: summe.toFixed(2) };
});
}
/** Die Buchungsarten, damit der Import sie nicht selbst herleiten muss. */
export const BUCHUNGSARTEN = [BuchungsArt.EINNAHME, BuchungsArt.AUSGABE] as const;
+8 -10
View File
@@ -67,9 +67,7 @@ test.describe("Anmeldung", () => {
await expect(page.getByRole("note")).toContainText("analoge Überwachung");
});
test("zeigt die Navigation und markiert künftige Module als noch nicht verfügbar", async ({
page,
}) => {
test("zeigt die Navigation mit allen umgesetzten Modulen", async ({ page }) => {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
@@ -95,13 +93,13 @@ test.describe("Anmeldung", () => {
navigation.getByRole("link", { name: "Benachrichtigungen" }),
).toBeVisible();
// Die Kasse kommt erst in Phase 9 — der Eintrag ist sichtbar, aber kein
// Verweis. Beim Abschluss einer Phase wandert diese Zusicherung eine Zeile
// weiter nach unten.
await expect(navigation.getByText("Kasse")).toBeVisible();
await expect(navigation.getByRole("link", { name: "Kasse" })).toHaveCount(
0,
);
// Die Kasse ist seit Phase 9 ein Verweis.
//
// Bis dahin stand hier die Gegenprobe: der Eintrag sichtbar, aber
// ausgegraut, weil das Modul noch fehlte. Mit Phase 9 gibt es kein
// künftiges Modul mehr — eine Zusicherung über ein Modul, das es nicht
// gibt, wäre keine. Kommt später eines dazu, gehört sie hierher zurück.
await expect(navigation.getByRole("link", { name: "Kasse" })).toBeVisible();
});
});
+258
View File
@@ -0,0 +1,258 @@
import { expect, test } from "@playwright/test";
import { anmelden, eigenerName } from "./wehr";
/**
* E2E-Tests für Phase 9 — die Kassenverwaltung.
*
* Was hier geprüft wird, ist nicht die Kür, sondern das, wovon die
* Kassenprüfung abhängt:
*
* * die Übersicht zeigt Bestände, die aus den Buchungen gerechnet sind
* * eine Buchung entsteht und erscheint im Kassenbuch
* * eine festgeschriebene Buchung lässt sich nicht mehr ändern — die
* Oberfläche bietet Gegenbuchen an, nicht Bearbeiten
* * die Beitragsliste zeigt Rückstände, nicht nur Summen
* * eine Rechnung entsteht als Entwurf mit fortlaufender Nummer
* * der geprüfte Zeitraum des Vorjahres ist zu
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet
* (`npm run db:test-reset`).
*/
test.describe.configure({ mode: "serial" });
const JAHR = new Date().getFullYear();
test.describe("Kassenübersicht", () => {
test("zeigt die Konten mit gerechnetem Bestand", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse");
await expect(page.getByRole("heading", { name: "Kasse", level: 1 })).toBeVisible();
// Aus dem Seed — die Überschriften der Kontenkarten, nicht irgendein
// Vorkommen des Wortes: "Barkasse" steht auch in Verwendungszwecken.
await expect(page.getByRole("heading", { name: "Barkasse" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Girokonto Raiffeisenbank" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Rücklage Fahrzeugbeschaffung" }),
).toBeVisible();
// Ein Bestand steht da, und zwar als Betrag — nicht als leeres Feld.
await expect(page.getByText(/\d+,\d{2}\s€/).first()).toBeVisible();
});
test("führt zu den vier Aufgaben der Kasse", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse");
for (const [name, pfad] of [
["Buchungen", "/kasse/buchungen"],
["Beiträge", "/kasse/beitraege"],
["Rechnungen", "/kasse/rechnungen"],
["Abschluss", "/kasse/abschluss"],
] as const) {
await expect(page.locator(`a[href="${pfad}"]`).first()).toBeVisible();
expect(name.length).toBeGreaterThan(0);
}
});
});
test.describe("Kassenbuch", () => {
test("zeigt die Buchungen mit Summen über alle Treffer", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/buchungen");
await expect(page.getByRole("heading", { name: "Buchungen", level: 1 })).toBeVisible();
// Die drei Summen oben. Sie beziehen sich auf alle Treffer, nicht nur auf
// die angezeigte Seite — sonst bedeutete die Zahl nichts.
await expect(page.getByText("Einnahmen", { exact: true })).toBeVisible();
await expect(page.getByText("Ausgaben", { exact: true })).toBeVisible();
await expect(page.getByText("Saldo", { exact: true })).toBeVisible();
// Aus dem Seed.
await expect(page.getByRole("cell", { name: /Maifest/ }).first()).toBeVisible();
});
test("erfasst eine Buchung und zeigt sie danach im Kassenbuch", async ({ page }) => {
const zweck = eigenerName("Buchung");
await anmelden(page);
await page.goto("/kasse/buchungen");
await page.getByRole("button", { name: "Buchung erfassen" }).click();
await page.getByLabel("Betrag", { exact: true }).fill("42.50");
await page.getByLabel("Verwendungszweck").fill(zweck);
// Kategorie wählen — die erste Ausgabenkategorie reicht.
await page.getByLabel("Kategorie", { exact: true }).click();
await page.getByRole("option").first().click();
await page.getByRole("button", { name: "Buchen" }).click();
// In der Zeile mit dem eigenen Verwendungszweck — der Betrag allein wäre
// nicht eindeutig, sobald ein zweiter Lauf dieselbe Buchung erzeugt hat.
const zeile = page.getByRole("row", { name: new RegExp(zweck) });
await expect(zeile).toBeVisible();
await expect(zeile.getByRole("cell", { name: "−42,50 €" })).toBeVisible();
});
test("bietet für festgeschriebene Buchungen Gegenbuchen statt Bearbeiten an", async ({
page,
}) => {
await anmelden(page);
await page.goto("/kasse/buchungen");
// Die Vorjahresbuchungen sind festgeschrieben — daran hängt die ganze
// Kassenprüfung. Die Oberfläche muss das zeigen, nicht nur der Server
// beim Versuch. Die Pressluftatmer gibt es im Seed nur im Vorjahr.
const fest = page.getByRole("row", { name: /Pressluftatmer/ });
await expect(fest).toBeVisible();
await expect(fest.getByRole("button", { name: "Gegenbuchen" })).toBeVisible();
await expect(fest.getByRole("button", { name: "Verwerfen" })).toHaveCount(0);
// Und die Gegenprobe: eine Buchung des laufenden Jahres ist noch ein
// Entwurf und darf verworfen werden.
const offen = page.getByRole("row", { name: /Kupplung MTW/ });
await expect(offen.getByRole("button", { name: "Verwerfen" })).toBeVisible();
await expect(offen.getByRole("button", { name: "Gegenbuchen" })).toHaveCount(0);
});
test("erklärt beim Gegenbuchen, warum beide Buchungen stehen bleiben", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/buchungen");
await page
.getByRole("row", { name: /Pressluftatmer/ })
.getByRole("button", { name: "Gegenbuchen" })
.click();
await expect(page.getByRole("dialog")).toContainText("Kassenprüfer");
// Ohne Grund geht es nicht weiter — er steht später im Verwendungszweck.
await expect(page.getByRole("button", { name: "Gegenbuchen" }).last()).toBeDisabled();
});
});
test.describe("Beiträge", () => {
test("zeigt Sollstellung, Offenes und die Rückstände", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/beitraege");
await expect(page.getByRole("heading", { name: "Mitgliedsbeiträge", level: 1 })).toBeVisible();
await expect(page.getByText("Sollstellung")).toBeVisible();
await expect(page.getByText("Bezahlt", { exact: true }).first()).toBeVisible();
// Die Sätze aus dem Seed.
await expect(page.getByText("Aktive und Passive")).toBeVisible();
await expect(page.getByText("Jugendfeuerwehr").first()).toBeVisible();
});
test("zeigt die Vorschau des Beitragslaufs, bevor etwas entsteht", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/beitraege");
await page.getByRole("button", { name: `Beitragslauf ${JAHR}` }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Das Jahr ist im Seed bereits gelaufen — der Lauf darf deshalb nichts
// doppelt anlegen und muss genau das sagen.
await expect(dialog).toContainText(/bereits einen|0 Beiträge/);
});
});
test.describe("Rechnungen", () => {
test("zeigt die Rechnungen aus dem Bestand mit ihrem Stand", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/rechnungen");
await expect(page.getByRole("heading", { name: "Rechnungen", level: 1 })).toBeVisible();
await expect(page.getByRole("cell", { name: `${JAHR}-0001` })).toBeVisible();
await expect(page.getByRole("cell", { name: /Sonnleitner/ })).toBeVisible();
await expect(page.getByRole("cell", { name: /Trachtenverein/ })).toBeVisible();
});
test("schreibt eine Rechnung mit fortlaufender Nummer", async ({ page }) => {
const empfaenger = eigenerName("Rechnung");
await anmelden(page);
await page.goto("/kasse/rechnungen");
await page.getByRole("button", { name: "Rechnung schreiben" }).click();
await page.getByLabel("Empfänger").fill(empfaenger);
await page.getByLabel("Bezeichnung").fill("Einsatz LF 20, angefangene Stunde");
await page.getByLabel("Einzelpreis").fill("396.00");
// Der Betrag wird gerechnet, nicht eingegeben — hier zur Anzeige, auf dem
// Server noch einmal verbindlich.
await expect(page.getByRole("dialog")).toContainText("396,00 €");
await page.getByRole("button", { name: "Als Entwurf anlegen" }).click();
await expect(page.getByText(/Rechnung \d{4}-\d{4} als Entwurf angelegt/)).toBeVisible();
await expect(page.getByRole("cell", { name: new RegExp(empfaenger) })).toBeVisible();
});
});
test.describe("Abschluss und Auswertung", () => {
test("zeigt die Kassenprüfung des Vorjahres als festgeschrieben", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/abschluss");
await expect(
page.getByRole("heading", { name: "Abschluss und Auswertung", level: 1 }),
).toBeVisible();
await expect(page.getByText("Festgeschrieben").first()).toBeVisible();
// Die Differenz aus dem Seed steht mit ihrer Erklärung da. Genau das ist
// der Zweck des Feldes: in fünf Jahren weiß sonst niemand mehr, woher sie
// kam.
await expect(page.getByText("−3,20 €")).toBeVisible();
await expect(page.getByText(/Wechselgeld/)).toBeVisible();
});
test("weist einen Abschluss ab, dessen Zeitraum schon geprüft ist", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/abschluss");
await page.getByRole("button", { name: "Kassenprüfung anlegen" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Der Dialog schlägt von sich aus das Vorjahr vor — und genau das ist
// bereits geprüft. Erst die Kontenliste abwarten: sie kommt vom Server,
// und ohne sie wären null Felder zu füllen.
await expect(dialog.getByRole("columnheader", { name: "Laut Buch" })).toBeVisible();
const felder = dialog.getByRole("spinbutton");
const anzahl = await felder.count();
for (let stelle = 0; stelle < anzahl; stelle += 1) {
await felder.nth(stelle).fill("100");
}
await dialog.getByRole("button", { name: "Als Entwurf anlegen" }).click();
await expect(page.getByText(/überschneidet sich mit einem bereits geprüften/)).toBeVisible();
});
test("zeigt die Jahresauswertung mit Vorjahresvergleich", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/abschluss");
await page.getByRole("button", { name: "Auswertung" }).click();
await expect(page.getByText(`Verlauf ${JAHR}`)).toBeVisible();
await expect(page.getByText("Einnahmen nach Kategorie")).toBeVisible();
await expect(page.getByText("Ausgaben nach Kategorie")).toBeVisible();
// Eine Zahl ohne Vergleich sagt nichts — das Vorjahr steht daneben.
await expect(page.getByText(new RegExp(`${JAHR - 1}:`)).first()).toBeVisible();
});
});
+218
View File
@@ -0,0 +1,218 @@
import { describe, expect, it } from "vitest";
import { auszugLesen, camtLesen, mt940Lesen } from "@/lib/kasse/bankimport";
/**
* Kontoauszüge einlesen.
*
* Die Beispiele sind bewusst so unaufgeräumt wie echte Dateien: mit
* Namensraum-Präfix, mit dem Verwendungszweck über mehrere Felder verteilt,
* mit Fortsetzungszeilen. Ein Parser, der nur an der eigenen sauberen
* Beispieldatei geprüft wurde, geht beim ersten echten Auszug kaputt.
*
* Wichtig ist außerdem, was der Parser NICHT tut: er bucht nichts. Er liefert
* Vorschläge, und alles, was er nicht lesen konnte, steht als Fehler daneben —
* statt still zu verschwinden.
*/
const CAMT = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct><Id><IBAN>DE02701500000000594937</IBAN></Id></Acct>
<Ntry>
<Amt Ccy="EUR">412.90</Amt>
<CdtDbtInd>DBIT</CdtDbtInd>
<BookgDt><Dt>2026-03-12</Dt></BookgDt>
<ValDt><Dt>2026-03-13</Dt></ValDt>
<AcctSvcrRef>2026031200123456</AcctSvcrRef>
<NtryDtls><TxDtls>
<RltdPties>
<Cdtr><Nm>Tankstelle Musterhausen</Nm></Cdtr>
<CdtrAcct><Id><IBAN>DE12500105170648489890</IBAN></Id></CdtrAcct>
</RltdPties>
<RmtInf>
<Ustrd>Sammelrechnung Februar</Ustrd>
<Ustrd>Diesel LF 20</Ustrd>
</RmtInf>
</TxDtls></NtryDtls>
</Ntry>
<Ntry>
<Amt Ccy="EUR">6500.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<BookgDt><Dt>2026-01-20</Dt></BookgDt>
<NtryDtls><TxDtls>
<RltdPties><Dbtr><Nm>Gemeinde Musterhausen</Nm></Dbtr></RltdPties>
<RmtInf><Ustrd>Zuschuss Feuerwehr 2026</Ustrd></RmtInf>
</TxDtls></NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`;
const MT940 = [
":20:STARTUMS",
":25:DE02701500000000594937",
":28C:00012/001",
":60F:C260301EUR4800,00",
":61:2603120312DR412,90NMSCNONREF",
":86:117?00UEBERWEISUNG?20Sammelrechnung Februar?21Diesel LF 20",
"?32Tankstelle Musterhausen",
":61:2601200120CR6500,00NTRFNONREF",
":86:166?00GUTSCHRIFT?20Zuschuss Feuerwehr 2026?32Gemeinde Musterhausen",
":62F:C260331EUR10887,10",
].join("\r\n");
describe("CAMT.053", () => {
it("liest Konto, Richtung, Betrag und Datum", () => {
const auszug = camtLesen(CAMT);
expect(auszug.format).toBe("CAMT.053");
expect(auszug.konto).toBe("DE02701500000000594937");
expect(auszug.fehler).toHaveLength(0);
expect(auszug.posten).toHaveLength(2);
const [ausgabe, einnahme] = auszug.posten;
expect(ausgabe?.art).toBe("AUSGABE");
expect(ausgabe?.betrag.toFixed(2)).toBe("412.90");
expect(ausgabe?.datum.toISOString().slice(0, 10)).toBe("2026-03-12");
expect(einnahme?.art).toBe("EINNAHME");
expect(einnahme?.betrag.toFixed(2)).toBe("6500.00");
});
it("setzt einen über mehrere Ustrd verteilten Verwendungszweck zusammen", () => {
const auszug = camtLesen(CAMT);
expect(auszug.posten[0]?.verwendungszweck).toBe("Sammelrechnung Februar Diesel LF 20");
});
it("holt die Gegenseite je nach Richtung aus Creditor oder Debtor", () => {
const auszug = camtLesen(CAMT);
expect(auszug.posten[0]?.gegenseite).toBe("Tankstelle Musterhausen");
expect(auszug.posten[0]?.gegenIban).toBe("DE12500105170648489890");
expect(auszug.posten[1]?.gegenseite).toBe("Gemeinde Musterhausen");
});
it("übernimmt die Kennung der Bank — daran hängt die Doppelerkennung", () => {
const auszug = camtLesen(CAMT);
expect(auszug.posten[0]?.kennung).toBe("2026031200123456");
});
it("liefert bei zweimaligem Einlesen dieselben Kennungen", () => {
// Der eigentliche Zweck: derselbe Auszug zweimal eingelesen darf nicht
// zwei Monatssätze in der Kasse ergeben.
const erste = camtLesen(CAMT).posten.map((posten) => posten.kennung);
const zweite = camtLesen(CAMT).posten.map((posten) => posten.kennung);
expect(erste).toEqual(zweite);
});
it("kommt mit Namensraum-Präfixen zurecht", () => {
const mitPraefix = CAMT.replace(/<(\/?)(Ntry|Amt|CdtDbtInd|Ustrd)\b/g, "<$1ns2:$2");
const auszug = camtLesen(mitPraefix);
expect(auszug.posten).toHaveLength(2);
});
it("meldet einen unbrauchbaren Umsatz, statt ihn stillschweigend zu übergehen", () => {
const kaputt = CAMT.replace("<CdtDbtInd>DBIT</CdtDbtInd>", "");
const auszug = camtLesen(kaputt);
expect(auszug.posten).toHaveLength(1);
expect(auszug.fehler).toHaveLength(1);
expect(auszug.fehler[0]?.stelle).toBe("Umsatz 1");
});
it("sagt es deutlich, wenn gar kein Umsatz drinsteht", () => {
const auszug = camtLesen("<Document></Document>");
expect(auszug.posten).toHaveLength(0);
expect(auszug.fehler[0]?.grund).toMatch(/Ntry/);
});
});
describe("MT940", () => {
it("liest Konto, Richtung, Betrag und Buchungsdatum", () => {
const auszug = mt940Lesen(MT940);
expect(auszug.format).toBe("MT940");
expect(auszug.konto).toBe("DE02701500000000594937");
expect(auszug.fehler).toHaveLength(0);
expect(auszug.posten).toHaveLength(2);
const [ausgabe, einnahme] = auszug.posten;
expect(ausgabe?.art).toBe("AUSGABE");
expect(ausgabe?.betrag.toFixed(2)).toBe("412.90");
// Buchungsdatum 0312 vor Valuta 260312 — beides März 2026.
expect(ausgabe?.datum.toISOString().slice(0, 10)).toBe("2026-03-12");
expect(einnahme?.art).toBe("EINNAHME");
expect(einnahme?.betrag.toFixed(2)).toBe("6500.00");
});
it("setzt den Verwendungszweck aus den Unterfeldern ?20 ff. zusammen", () => {
const auszug = mt940Lesen(MT940);
expect(auszug.posten[0]?.verwendungszweck).toBe("Sammelrechnung Februar Diesel LF 20");
});
it("hängt Fortsetzungszeilen an, statt sie zu verlieren", () => {
// ?32 steht im Beispiel auf einer eigenen Zeile ohne Kennsatz.
const auszug = mt940Lesen(MT940);
expect(auszug.posten[0]?.gegenseite).toBe("Tankstelle Musterhausen");
});
it("liest den Jahreswechsel im Auszug richtig", () => {
// Valuta 260105, Buchung 1229: das Buchungsdatum stammt aus dem Vorjahr.
const auszug = mt940Lesen(
[":25:DE02701500000000594937", ":61:2601051229DR36,00NMSCNONREF", ":86:117?20Kontofuehrung"].join(
"\n",
),
);
expect(auszug.posten[0]?.datum.toISOString().slice(0, 10)).toBe("2025-12-29");
});
it("kehrt bei Rückbuchungen die Richtung um", () => {
const auszug = mt940Lesen(
[":25:DE02701500000000594937", ":61:2603120312RD120,00NMSCNONREF", ":86:117?20Ruecklastschrift"].join(
"\n",
),
);
// RD ist die Rückgabe einer Lastschrift — das Geld kommt zurück.
expect(auszug.posten[0]?.art).toBe("EINNAHME");
});
it("meldet eine unlesbare Umsatzzeile mit ihrer Nummer", () => {
const auszug = mt940Lesen(
[":25:DE02701500000000594937", ":61:Unsinn", ":86:117?20egal"].join("\n"),
);
expect(auszug.posten).toHaveLength(0);
expect(auszug.fehler[0]?.stelle).toBe("Umsatz 1");
});
it("kommt ohne :86: aus und vergibt trotzdem eine Kennung", () => {
const auszug = mt940Lesen(
[":25:DE02701500000000594937", ":61:2603120312DR412,90NMSCNONREF"].join("\n"),
);
expect(auszug.posten).toHaveLength(1);
expect(auszug.posten[0]?.verwendungszweck).toBe("(kein Verwendungszweck)");
expect(auszug.posten[0]?.kennung).not.toBe("");
});
});
describe("Formaterkennung", () => {
it("erkennt CAMT am Inhalt, nicht an der Endung", () => {
expect(auszugLesen(CAMT).format).toBe("CAMT.053");
});
it("erkennt MT940 an den Kennsätzen", () => {
expect(auszugLesen(MT940).format).toBe("MT940");
});
it("sagt bei einem unbekannten Format, was erwartet wird", () => {
const auszug = auszugLesen("Datum;Betrag;Zweck\n12.03.2026;-412,90;Diesel");
expect(auszug.posten).toHaveLength(0);
expect(auszug.fehler[0]?.grund).toMatch(/CAMT\.053|MT940/);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { Prisma } from "@prisma/client";
import { describe, expect, it } from "vitest";
import {
alsText,
alsWirkung,
bestand,
betrag,
betragLesen,
bilanz,
positionssumme,
runden,
summe,
} from "@/lib/kasse/betrag";
/**
* Das Rechnen mit Geld.
*
* Der Grund für diese Datei ist eine einzige Zeile, die es hier nicht geben
* darf: `0.1 + 0.2`. In Fließkomma ergibt das 0.30000000000000004, und in
* einer Kasse ist das kein Rundungsfehler, sondern eine Differenz in der
* Kassenprüfung, die niemand erklären kann. Deshalb rechnet die gesamte
* Kasse mit `Prisma.Decimal`, und deshalb prüft dieser Test genau das nach.
*/
describe("Beträge lesen", () => {
it("nimmt Zahl, Text und Decimal entgegen", () => {
expect(betrag(12.5).toFixed(2)).toBe("12.50");
expect(betrag("12.50").toFixed(2)).toBe("12.50");
expect(betrag(new Prisma.Decimal("12.5")).toFixed(2)).toBe("12.50");
});
it("macht aus fehlenden Angaben null Euro", () => {
expect(betrag(null).toFixed(2)).toBe("0.00");
expect(betrag(undefined).toFixed(2)).toBe("0.00");
expect(betrag("").toFixed(2)).toBe("0.00");
});
});
describe("Summe", () => {
it("rechnet ohne Fließkommafehler", () => {
// Die Probe aufs Exempel: in `number` ergäbe das 0.30000000000000004.
expect(summe(["0.10", "0.20"]).toFixed(2)).toBe("0.30");
});
it("addiert eine ganze Kassenseite auf den Cent", () => {
const werte = ["1284.50", "412.90", "2340.00", "868.00", "1150.00", "624.00", "398.20", "36.00"];
expect(summe(werte).toFixed(2)).toBe("7113.60");
});
it("ergibt bei leerer Liste null Euro und nicht undefined", () => {
expect(summe([]).toFixed(2)).toBe("0.00");
});
});
describe("Runden", () => {
it("rundet kaufmännisch — ab der halben Stelle nach oben", () => {
expect(runden(new Prisma.Decimal("2.345")).toFixed(2)).toBe("2.35");
expect(runden(new Prisma.Decimal("2.344")).toFixed(2)).toBe("2.34");
// Und nicht bankers rounding: 2,355 wird 2,36, nicht 2,36-oder-2,35.
expect(runden(new Prisma.Decimal("2.355")).toFixed(2)).toBe("2.36");
expect(runden(new Prisma.Decimal("2.365")).toFixed(2)).toBe("2.37");
});
});
describe("Richtung", () => {
it("macht aus Art und positivem Betrag ein Vorzeichen", () => {
expect(alsWirkung("EINNAHME", "100.00").toFixed(2)).toBe("100.00");
expect(alsWirkung("AUSGABE", "100.00").toFixed(2)).toBe("-100.00");
});
});
describe("Bestand", () => {
it("ist Anfangsbestand plus die Wirkung aller Buchungen", () => {
const wert = bestand("250.00", [
{ art: "EINNAHME", betrag: "3860.00" },
{ art: "AUSGABE", betrag: "1420.60" },
]);
expect(wert.toFixed(2)).toBe("2689.40");
});
it("darf negativ werden — eine überzogene Barkasse ist ein Befund, kein Fehler", () => {
expect(bestand("10.00", [{ art: "AUSGABE", betrag: "50.00" }]).toFixed(2)).toBe("-40.00");
});
});
describe("Bilanz", () => {
it("trennt Einnahmen und Ausgaben und bildet den Saldo", () => {
const werte = bilanz([
{ art: "EINNAHME", betrag: "6500.00" },
{ art: "EINNAHME", betrag: "780.00" },
{ art: "AUSGABE", betrag: "1284.50" },
]);
expect(werte.einnahmen.toFixed(2)).toBe("7280.00");
expect(werte.ausgaben.toFixed(2)).toBe("1284.50");
expect(werte.saldo.toFixed(2)).toBe("5995.50");
});
});
describe("Positionssumme", () => {
it("rechnet Viertelstunden auf den Cent", () => {
// 0,25 × 47,50 = 11,875 — gerundet 11,88 und nicht 11,87.
expect(positionssumme("0.25", "47.50").toFixed(2)).toBe("11.88");
});
it("rechnet ganze Mengen ohne Rest", () => {
expect(positionssumme(9, "24.00").toFixed(2)).toBe("216.00");
});
});
describe("Eingaben lesen", () => {
it("versteht die deutsche Schreibweise", () => {
expect(betragLesen("1.234,56")?.toFixed(2)).toBe("1234.56");
expect(betragLesen("1.234,56 €")?.toFixed(2)).toBe("1234.56");
expect(betragLesen("12,50 EUR")?.toFixed(2)).toBe("12.50");
});
it("versteht die englische Schreibweise", () => {
expect(betragLesen("1,234.56")?.toFixed(2)).toBe("1234.56");
expect(betragLesen("$1234.56")?.toFixed(2)).toBe("1234.56");
});
it("liest auch Beträge ohne Trennzeichen", () => {
expect(betragLesen("42")?.toFixed(2)).toBe("42.00");
expect(betragLesen("-42,50")?.toFixed(2)).toBe("-42.50");
});
it("gibt bei Unlesbarem null zurück — und nicht null Euro", () => {
// Der Unterschied ist der ganze Punkt: null Euro ist eine Angabe,
// unlesbar ist eine Rückfrage an den, der es eingetippt hat.
expect(betragLesen("etwa zwanzig")).toBeNull();
expect(betragLesen("")).toBeNull();
expect(betragLesen("1,2,3")).toBeNull();
});
});
describe("Beträge schreiben", () => {
it("setzt Tausenderpunkte und ein Komma", () => {
expect(alsText("1234.56")).toBe("1.234,56 €");
expect(alsText("12.50")).toBe("12,50 €");
expect(alsText("1234567.89")).toBe("1.234.567,89 €");
});
it("verwendet das typografische Minus", () => {
expect(alsText("-3.20")).toBe("−3,20 €");
});
it("macht aus fehlenden Angaben einen Gedankenstrich", () => {
expect(alsText(null)).toBe("—");
expect(alsText(undefined)).toBe("—");
});
});
+426
View File
@@ -0,0 +1,426 @@
import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* Die Absicherungen der Kasse — geprüft dort, wo sie durchgesetzt werden: in
* der Datenbank.
*
* 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. Ein Zustandscheck im Router
* reicht dafür nicht: ein vergessener Zweig, ein Skript, ein Import — und der
* geprüfte Bestand stimmt nicht mehr, ohne dass irgendwo etwas auffällt.
*
* Deshalb sitzen die Regeln in Triggern und CHECKs (Migration 20260904140000),
* und deshalb prüft dieser Test sie über den Systemzugang: wenn nicht einmal
* der Eigentümer der Datenbank daran vorbeikommt, kommt niemand vorbei.
*
* Der Test braucht eine laufende Datenbank. Ist keine erreichbar, überspringt
* er sich selbst, damit "npm run test" auch ohne Docker durchläuft.
*/
const APP_URL = process.env.DATABASE_URL ?? "";
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
const SLUG = "test-kassen-wehr";
const FREMD_SLUG = "test-kassen-fremdwehr";
let erreichbar = false;
let organisationId = "";
let fremdOrganisationId = "";
let kontoId = "";
let kategorieId = "";
let prueferId = "";
/** Ein Datum weit in der Vergangenheit, damit nichts mit dem Seed kollidiert. */
function tag(jahr: number, monat: number, tagImMonat: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tagImMonat));
}
beforeAll(async () => {
try {
await system.$queryRaw`SELECT 1`;
erreichbar = true;
} catch {
return;
}
const organisation = await system.organisation.upsert({
where: { slug: SLUG },
update: {},
create: { slug: SLUG, name: "Kassen-Testwehr", kurzname: "KTW" },
});
organisationId = organisation.id;
const fremd = await system.organisation.upsert({
where: { slug: FREMD_SLUG },
update: {},
create: { slug: FREMD_SLUG, name: "Kassen-Fremdwehr", kurzname: "KFW" },
});
fremdOrganisationId = fremd.id;
const konto = await system.kassenkonto.upsert({
where: { organisationId_bezeichnung: { organisationId, bezeichnung: "Testkasse" } },
update: {},
create: { organisationId, bezeichnung: "Testkasse", art: "BAR", anfangsbestand: "100.00" },
});
kontoId = konto.id;
const kategorie = await system.buchungskategorie.upsert({
where: { organisationId_name: { organisationId, name: "Testkategorie" } },
update: {},
create: { organisationId, name: "Testkategorie", art: "AUSGABE" },
});
kategorieId = kategorie.id;
// Ohne Prüfer lässt sich kein Abschluss festschreiben — dafür sorgt
// ein eigener CHECK, den ein Test weiter unten prüft.
const person = await system.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "KTW-P" } },
update: {},
create: {
organisationId,
vorname: "Prüfer",
nachname: "Testwehr",
mitgliedsnummer: "KTW-P",
eintrittsdatum: tag(2015, 1, 1),
},
});
prueferId = person.id;
});
afterAll(async () => {
if (erreichbar) {
// Der Abschluss ist für die Anwendung unlöschbar; hier räumt der
// Eigentümer auf, dem das REVOKE nicht gilt.
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: { in: [SLUG, FREMD_SLUG] } } });
});
}
await system.$disconnect();
});
/** Eine Buchung anlegen — wahlweise gleich festgeschrieben. */
async function buchung(datum: Date, betrag: string, festgeschrieben = false) {
return system.buchung.create({
data: {
organisationId,
kontoId,
kategorieId,
datum,
art: "AUSGABE",
betrag,
verwendungszweck: "Prüfling",
festgeschriebenAm: festgeschrieben ? new Date() : null,
},
});
}
describe.runIf(process.env.DATABASE_URL)("Festgeschriebene Buchungen", () => {
it("lässt Entwürfe ändern und verwerfen", async ({ skip }) => {
if (!erreichbar) return skip();
const entwurf = await buchung(tag(2019, 3, 1), "12.00");
const geaendert = await system.buchung.update({
where: { id: entwurf.id },
data: { betrag: "15.00", verwendungszweck: "korrigiert" },
});
expect(geaendert.betrag.toFixed(2)).toBe("15.00");
await system.buchung.delete({ where: { id: entwurf.id } });
});
it("weist jede inhaltliche Änderung an einer festgeschriebenen Buchung ab", async ({ skip }) => {
if (!erreichbar) return skip();
const fest = await buchung(tag(2019, 3, 2), "42.00", true);
await expect(
system.buchung.update({ where: { id: fest.id }, data: { betrag: "43.00" } }),
).rejects.toThrow(/festgeschrieben/i);
await expect(
system.buchung.update({ where: { id: fest.id }, data: { verwendungszweck: "anders" } }),
).rejects.toThrow(/festgeschrieben/i);
await expect(
system.buchung.update({ where: { id: fest.id }, data: { datum: tag(2019, 4, 1) } }),
).rejects.toThrow(/festgeschrieben/i);
});
it("lässt sie auch nicht löschen", async ({ skip }) => {
if (!erreichbar) return skip();
const fest = await buchung(tag(2019, 3, 3), "42.00", true);
await expect(system.buchung.delete({ where: { id: fest.id } })).rejects.toThrow(
/festgeschrieben/i,
);
});
it("lässt den Verweis auf die Gegenbuchung eintragen", async ({ skip }) => {
if (!erreichbar) return skip();
// Die eine erlaubte Ausnahme: eine Stornierung entsteht später. Sie
// einzutragen ist keine Änderung des Vorgangs, sondern seine
// Fortschreibung — und ohne diesen Weg gäbe es keine Korrekturmöglichkeit.
const original = await buchung(tag(2019, 3, 4), "42.00", true);
const gegen = await buchung(tag(2019, 3, 5), "42.00", true);
const verknuepft = await system.buchung.update({
where: { id: original.id },
data: { storniertVonId: gegen.id },
});
expect(verknuepft.storniertVonId).toBe(gegen.id);
});
it("lässt sich das Festschreiben nicht wieder zurücknehmen", async ({ skip }) => {
if (!erreichbar) return skip();
const fest = await buchung(tag(2019, 3, 6), "42.00", true);
await expect(
system.buchung.update({ where: { id: fest.id }, data: { festgeschriebenAm: null } }),
).rejects.toThrow(/festgeschrieben/i);
});
});
describe.runIf(process.env.DATABASE_URL)("Geprüfter Zeitraum", () => {
it("nimmt keine neue Buchung mehr an und lässt keine hineindatieren", async ({ skip }) => {
if (!erreichbar) return skip();
// Erst die Buchungen, dann der Abschluss — die Reihenfolge, die auch der
// Kassier einhalten muss.
const spaeter = await buchung(tag(2020, 6, 1), "10.00");
await system.kassenabschluss.create({
data: {
organisationId,
vonAm: tag(2019, 1, 1),
bisAm: tag(2019, 12, 31),
bestandLautBuch: "100.00",
gezaehlterBestand: "100.00",
differenz: "0.00",
prueferId,
geprueftAm: tag(2020, 2, 1),
festgeschriebenAm: new Date(),
},
});
// Neu in den geprüften Zeitraum: abgelehnt.
await expect(buchung(tag(2019, 7, 1), "5.00")).rejects.toThrow(/geprueft|abgeschlossen/i);
// Eine vorhandene hineinschieben: ebenso. Ohne diese zweite Hälfte
// stimmte der geprüfte Bestand nach einem Datumswechsel nicht mehr.
await expect(
system.buchung.update({ where: { id: spaeter.id }, data: { datum: tag(2019, 7, 1) } }),
).rejects.toThrow(/geprueft|abgeschlossen/i);
// Außerhalb des Zeitraums bleibt alles offen.
const danach = await buchung(tag(2020, 6, 2), "5.00");
expect(danach.id).toBeTruthy();
});
it("hält den festgeschriebenen Abschluss selbst fest — bis auf den Bericht", async ({ skip }) => {
if (!erreichbar) return skip();
const abschluss = await system.kassenabschluss.create({
data: {
organisationId,
vonAm: tag(2021, 1, 1),
bisAm: tag(2021, 12, 31),
bestandLautBuch: "100.00",
gezaehlterBestand: "97.00",
differenz: "-3.00",
erlaeuterung: "Wechselgeld vom Fest, Beleg fehlt.",
prueferId,
geprueftAm: tag(2022, 2, 1),
festgeschriebenAm: new Date(),
},
});
await expect(
system.kassenabschluss.update({
where: { id: abschluss.id },
data: { gezaehlterBestand: "100.00", differenz: "0.00" },
}),
).rejects.toThrow(/festgeschrieben/i);
await expect(
system.kassenabschluss.update({
where: { id: abschluss.id },
data: { erlaeuterung: "doch alles in Ordnung" },
}),
).rejects.toThrow(/festgeschrieben/i);
// Der Prüfbericht entsteht im Hintergrund, also nach dem Festschreiben.
// Ihn nachzutragen muss offen bleiben — sonst gäbe es nie einen.
const datei = await system.datei.create({
data: {
organisationId,
bucket: "test",
objektSchluessel: `test/${abschluss.id}.pdf`,
dateiname: "pruefbericht.pdf",
mimeTyp: "application/pdf",
groesseBytes: BigInt(2048),
},
});
const mitBericht = await system.kassenabschluss.update({
where: { id: abschluss.id },
data: { berichtDateiId: datei.id },
});
expect(mitBericht.berichtDateiId).toBe(datei.id);
});
});
describe.runIf(process.env.DATABASE_URL)("Was die Datenbank gar nicht erst annimmt", () => {
it("keine Buchung mit einem Betrag von null oder darunter", async ({ skip }) => {
if (!erreichbar) return skip();
// Der Betrag ist immer positiv, die Richtung steht in `art`. Eine
// negative Ausgabe wäre eine Einnahme in Verkleidung — und in keiner
// Auswertung als solche zu erkennen.
await expect(buchung(tag(2023, 1, 1), "0.00")).rejects.toThrow(/betrag_positiv/i);
await expect(buchung(tag(2023, 1, 2), "-5.00")).rejects.toThrow(/betrag_positiv/i);
});
it("keine Buchung, die zugleich an eine Person und an einen Dritten geht", async ({ skip }) => {
if (!erreichbar) return skip();
const person = await system.person.create({
data: {
organisationId,
vorname: "Test",
nachname: "Person",
mitgliedsnummer: "KTW-1",
eintrittsdatum: tag(2015, 1, 1),
},
});
await expect(
system.buchung.create({
data: {
organisationId,
kontoId,
kategorieId,
datum: tag(2023, 2, 1),
art: "AUSGABE",
betrag: "10.00",
verwendungszweck: "beides",
personId: person.id,
lieferant: "Werkstatt Müller",
},
}),
).rejects.toThrow(/gegenueber_eindeutig/i);
});
it("kein erlassener Beitrag ohne Begründung", async ({ skip }) => {
if (!erreichbar) return skip();
const person = await system.person.create({
data: {
organisationId,
vorname: "Erlass",
nachname: "Person",
mitgliedsnummer: "KTW-2",
eintrittsdatum: tag(2015, 1, 1),
},
});
// Ein erlassener Beitrag ohne Begründung ist in der Kassenprüfung eine
// offene Frage — und fünf Jahre später weiß es niemand mehr.
await expect(
system.beitrag.create({
data: { organisationId, personId: person.id, jahr: 2023, betrag: "25.00", stand: "ERLASSEN" },
}),
).rejects.toThrow(/erlass_begruendet/i);
const mitGrund = await system.beitrag.create({
data: {
organisationId,
personId: person.id,
jahr: 2023,
betrag: "25.00",
stand: "ERLASSEN",
erlassGrund: "Längere Erkrankung",
},
});
expect(mitGrund.id).toBeTruthy();
});
it("keinen Abschluss, dessen Differenz nicht zu seinen Zahlen passt", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
system.kassenabschluss.create({
data: {
organisationId,
vonAm: tag(2024, 1, 1),
bisAm: tag(2024, 12, 31),
bestandLautBuch: "100.00",
gezaehlterBestand: "90.00",
// Gerechnet wären das −10,00. Eine geschönte Differenz wäre genau
// das, was eine Kassenprüfung finden soll.
differenz: "0.00",
},
}),
).rejects.toThrow(/differenz_stimmt/i);
});
it("keine Rechnung, die fällig wird, bevor sie geschrieben wurde", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
system.rechnung.create({
data: {
organisationId,
nummer: "TEST-0001",
empfaenger: "Irgendwer",
datum: tag(2024, 5, 1),
faelligAm: tag(2024, 4, 1),
betrag: "100.00",
},
}),
).rejects.toThrow(/faellig_nach_datum/i);
});
});
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung", () => {
it("trennt die Kassen zweier Wehren über Row Level Security", async ({ skip }) => {
if (!erreichbar) return skip();
if (!APP_URL || APP_URL === SYSTEM_URL) return skip();
// Der Anwendungszugang unterliegt RLS. Setzt er den Kontext der fremden
// Wehr, darf er von der Testkasse nichts sehen — nicht einmal, dass es
// sie gibt.
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
try {
const sichtbar = await app.$transaction(async (tx) => {
await tx.$executeRawUnsafe(
`SELECT set_config('app.current_organisation', '${fremdOrganisationId}', true)`,
);
return tx.buchung.count({ where: { organisationId } });
});
expect(sichtbar).toBe(0);
const eigene = await app.$transaction(async (tx) => {
await tx.$executeRawUnsafe(
`SELECT set_config('app.current_organisation', '${organisationId}', true)`,
);
return tx.buchung.count();
});
expect(eigene).toBeGreaterThan(0);
} finally {
await app.$disconnect();
}
});
});
+200
View File
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
import {
STANDARDKATEGORIEN,
aehnlichkeit,
kategorieVorschlagen,
woerter,
} from "@/lib/kasse/vorschlag";
/**
* Der Kategorievorschlag.
*
* Sein Nutzen liegt nicht in den gesparten Sekunden, sondern darin, dass
* dieselbe Sache immer unter derselben Kategorie landet — erst das macht die
* Jahresauswertung brauchbar. Zwei Kategorien für Diesel, weil einmal
* „Kraftstoff" und einmal „Fahrzeuge" gewählt wurde, sind in der
* Jahreshauptversammlung nicht zu erklären.
*
* Ebenso wichtig ist, wann er schweigt: ein falscher Vorschlag wird
* übernommen, ohne gelesen zu werden. Deshalb die Schwelle bei der Hälfte
* gemeinsamer Wörter — und deshalb prüft dieser Test beide Seiten.
*/
const KATEGORIEN = [
{ id: "k-kraftstoff", name: "Kraftstoff", art: "AUSGABE" as const },
{ id: "k-reparatur", name: "Reparatur", art: "AUSGABE" as const },
{ id: "k-strom", name: "Wasser und Strom", art: "AUSGABE" as const },
{ id: "k-zuschuss", name: "Zuschüsse", art: "EINNAHME" as const },
{ id: "k-beitrag", name: "Mitgliedsbeiträge", art: "EINNAHME" as const },
];
describe("Wörter", () => {
it("wirft Füllwörter, kurze Wörter und reine Zahlen weg", () => {
expect(woerter("Rechnung Nr. 4711 vom 12.03. für die Feuerwehr GmbH")).toEqual([]);
});
it("behält, was unterscheidet", () => {
expect(woerter("Tankstelle Musterhausen Diesel LF 20")).toEqual([
"tankstelle",
"musterhausen",
"diesel",
]);
});
it("lässt Umlaute stehen", () => {
expect(woerter("Prüfung Drehleiter")).toEqual(["prüfung", "drehleiter"]);
});
});
describe("Ähnlichkeit", () => {
it("misst am kürzeren der beiden Texte", () => {
// "stadtwerke musterhausen" steckt vollständig in beiden.
const wert = aehnlichkeit(
"Stadtwerke Musterhausen Abschlag Strom",
"Stadtwerke Musterhausen Jahresabrechnung Strom und Wasser",
);
expect(wert).toBeGreaterThanOrEqual(0.5);
});
it("ergibt null bei nichts Gemeinsamem", () => {
expect(aehnlichkeit("Diesel Tankstelle", "Einsatzjacken Beschaffung")).toBe(0);
});
it("ergibt null, wenn nach dem Aussieben nichts übrig ist", () => {
expect(aehnlichkeit("Rechnung vom", "Rechnung Nr. 12")).toBe(0);
});
});
describe("Vorschlag aus dem eigenen Bestand", () => {
const vorbilder = [
{
kategorieId: "k-kraftstoff",
kategorieName: "Kraftstoff",
verwendungszweck: "Tankstelle Musterhausen Diesel LF 20 Sammelrechnung",
art: "AUSGABE" as const,
},
{
kategorieId: "k-reparatur",
kategorieName: "Reparatur",
verwendungszweck: "Werkstatt Müller Kupplung MTW",
art: "AUSGABE" as const,
},
];
it("nimmt die Kategorie, unter der dasselbe zuletzt gebucht wurde", () => {
const vorschlag = kategorieVorschlagen(
"Tankstelle Musterhausen Diesel MTW",
"AUSGABE",
vorbilder,
KATEGORIEN,
);
expect(vorschlag?.kategorieId).toBe("k-kraftstoff");
expect(vorschlag?.grund).toMatch(/zuletzt so gebucht/);
});
it("geht dem Bestand vor den Stichwörtern nach — die Wehr kennt sich selbst am besten", () => {
const eigen = [
{
kategorieId: "k-strom",
kategorieName: "Wasser und Strom",
// Diese Wehr bucht Diesel unter Wasser und Strom. Merkwürdig, aber
// ihre Entscheidung — und sie soll dabei bleiben dürfen.
verwendungszweck: "Tankstelle Musterhausen Diesel LF 20",
art: "AUSGABE" as const,
},
];
const vorschlag = kategorieVorschlagen(
"Tankstelle Musterhausen Diesel LF 20",
"AUSGABE",
eigen,
KATEGORIEN,
);
expect(vorschlag?.kategorieId).toBe("k-strom");
});
it("schweigt bei zufälliger Übereinstimmung, statt zu raten", () => {
const vorschlag = kategorieVorschlagen(
"Möbelhaus Sonnleitner Tischbeschaffung Schulungsraum",
"AUSGABE",
vorbilder,
KATEGORIEN,
);
expect(vorschlag).toBeNull();
});
it("mischt Einnahmen und Ausgaben nicht", () => {
const vorschlag = kategorieVorschlagen(
"Tankstelle Musterhausen Diesel LF 20 Sammelrechnung",
"EINNAHME",
vorbilder,
KATEGORIEN,
);
// Der Vorgänger ist eine Ausgabe und kommt deshalb nicht in Frage; ein
// Stichwort für Einnahmen trifft hier ebenso wenig.
expect(vorschlag?.kategorieId).not.toBe("k-kraftstoff");
});
});
describe("Vorschlag aus Stichwörtern", () => {
it("greift, solange es keinen Bestand gibt", () => {
const vorschlag = kategorieVorschlagen("Diesel für das LF 20", "AUSGABE", [], KATEGORIEN);
expect(vorschlag?.kategorieId).toBe("k-kraftstoff");
expect(vorschlag?.grund).toMatch(/Verwendungszweck/);
// Niedrigere Sicherheit als ein Treffer aus dem Bestand: ein Stichwort
// weiß nichts über die Gepflogenheiten dieser Wehr.
expect(vorschlag?.sicherheit).toBeLessThan(1);
});
it("erkennt den Gemeindezuschuss als Einnahme", () => {
const vorschlag = kategorieVorschlagen(
"Zuschuss der Gemeinde für den laufenden Betrieb",
"EINNAHME",
[],
KATEGORIEN,
);
expect(vorschlag?.kategorieId).toBe("k-zuschuss");
});
it("schlägt nichts vor, wenn es die Kategorie in dieser Wehr nicht gibt", () => {
const vorschlag = kategorieVorschlagen("Neue Einsatzjacken", "AUSGABE", [], KATEGORIEN);
expect(vorschlag).toBeNull();
});
it("schweigt bei leerem Verwendungszweck", () => {
expect(kategorieVorschlagen(" ", "AUSGABE", [], KATEGORIEN)).toBeNull();
});
});
describe("Standardkontenrahmen", () => {
it("verweist nur auf Oberkategorien, die es auch gibt", () => {
const namen = new Set(STANDARDKATEGORIEN.map((eintrag) => eintrag.name));
for (const eintrag of STANDARDKATEGORIEN) {
if (eintrag.oberkategorie) {
expect(namen.has(eintrag.oberkategorie)).toBe(true);
}
}
});
it("gibt jeden Namen nur einmal — die Datenbank verlangt es so", () => {
const namen = STANDARDKATEGORIEN.map((eintrag) => eintrag.name);
expect(new Set(namen).size).toBe(namen.length);
});
it("stellt jede Unterkategorie in eine Oberkategorie derselben Richtung", () => {
const nachName = new Map(STANDARDKATEGORIEN.map((eintrag) => [eintrag.name, eintrag]));
for (const eintrag of STANDARDKATEGORIEN) {
if (eintrag.oberkategorie) {
expect(nachName.get(eintrag.oberkategorie)?.art).toBe(eintrag.art);
}
}
});
});