Phase 9: Kassenverwaltung (#14)
@@ -6,11 +6,168 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach
|
|||||||
|
|
||||||
Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
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
|
Aufräumarbeiten vor Phase 9. Drei Punkte, die sich über mehrere Phasen
|
||||||
angesammelt hatten und in jeder Phase Zeit gekostet haben.
|
angesammelt hatten und in jeder Phase Zeit gekostet haben.
|
||||||
|
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|
||||||
- **ESLint** hat endlich eine Konfiguration (`eslint.config.mjs`, Flat Config).
|
- **ESLint** hat endlich eine Konfiguration (`eslint.config.mjs`, Flat Config).
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
|||||||
| Phase 6 — Kartenmodule | fertig |
|
| Phase 6 — Kartenmodule | fertig |
|
||||||
| Phase 7 — Benachrichtigungen und Termine | fertig |
|
| Phase 7 — Benachrichtigungen und Termine | fertig |
|
||||||
| Phase 8 — Lokale KI | 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
|
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
|
||||||
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
|
[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
|
## Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
# Anleitung
|
# 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.
|
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
|
## 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
|
Ohne Vision-Modell funktioniert alles bis auf das Auslesen: der Beleg wird
|
||||||
fotografiert und abgelegt, die Angaben tragen Sie von Hand ein.
|
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
|
Ist der Beleg gegengezeichnet, steht in der Liste der erledigten Belege ein
|
||||||
sauber erfasster Beleg mit Foto — und der ist schon mehr wert als ein
|
Knopf **Buchen**. Er führt in einem Schritt ins Kassenbuch — zu wählen sind
|
||||||
Schuhkarton.
|
nur Konto und Kategorie. Siehe [Kapitel 48](#48-buchen).
|
||||||
|
|
||||||
### Das KI-Protokoll
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
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
|
`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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
|
||||||
| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen |
|
| **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 |
|
| **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.
|
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,
|
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
||||||
was dahinter liegt.
|
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,
|
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
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 |
|
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ Faktoren, die in die Schätzung eingehen:
|
|||||||
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** |
|
| 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** |
|
| 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** |
|
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** |
|
||||||
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
|
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 h** |
|
||||||
| | **Summe** | **123–164 PT** | **28–37 h** | |
|
| | **Summe** | **123–164 PT** | **28–37 h** | **28:30 h** |
|
||||||
|
|
||||||
123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
|
123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
|
||||||
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
|
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
|
das Muster steht seit Phase 0 mit dem Audit-Log-Trigger und lässt sich
|
||||||
übernehmen.
|
ü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
|
## Was hier nicht drin ist
|
||||||
|
|||||||
@@ -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 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
|
||||||
| Phase 8 — Lokale KI | 3:40 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 |
|
| 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Phase 5 ████████████████████ fertig
|
|||||||
Phase 6 ████████████████████ fertig 0.7.0
|
Phase 6 ████████████████████ fertig 0.7.0
|
||||||
Phase 7 ████████████████████ fertig 0.8.0
|
Phase 7 ████████████████████ fertig 0.8.0
|
||||||
Phase 8 ████████████████████ fertig 0.9.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
|
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
|
schreiben, Wortsuche, Belege ablegen, beide Protokolle. Wo etwas fehlt, steht
|
||||||
ein Satz, der sagt was fehlt und wie es ohne geht.
|
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
|
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`.
|
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
|
kein Datum daraus mehr an. Beträge durchgängig als `DECIMAL(12,2)`, nie als
|
||||||
Fließkommazahl.
|
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.
|
Knopfdruck erzeugen.
|
||||||
|
|
||||||
## 1.0.0 — Freigabe
|
## 1.0.0 — Freigabe
|
||||||
@@ -276,7 +280,7 @@ Am 03.09.2026 entschieden:
|
|||||||
|
|
||||||
| Punkt | Entscheidung |
|
| 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. |
|
| `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. |
|
| 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. |
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 726 KiB After Width: | Height: | Size: 727 KiB |
|
Before Width: | Height: | Size: 742 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 408 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 129 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||||
"engines": {
|
"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();
|
||||||
@@ -234,6 +234,17 @@ model Organisation {
|
|||||||
diktate Diktat[]
|
diktate Diktat[]
|
||||||
belegentwuerfe Belegentwurf[]
|
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])
|
@@index([aktiv])
|
||||||
@@map("organisation")
|
@@map("organisation")
|
||||||
}
|
}
|
||||||
@@ -356,6 +367,14 @@ model Person {
|
|||||||
erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser")
|
erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser")
|
||||||
bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger")
|
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
|
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||||
/// Person kann in zwei Wehren Mitglied sein.
|
/// Person kann in zwei Wehren Mitglied sein.
|
||||||
@@unique([organisationId, email])
|
@@unique([organisationId, email])
|
||||||
@@ -602,6 +621,11 @@ model Datei {
|
|||||||
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
|
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
|
||||||
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
|
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
|
||||||
|
|
||||||
|
// Phase 9
|
||||||
|
alsBuchungsbeleg Buchung[] @relation("BuchungBeleg")
|
||||||
|
alsRechnungsPdf Rechnung[] @relation("RechnungPdf")
|
||||||
|
alsKassenpruefbericht Kassenabschluss[] @relation("AbschlussBericht")
|
||||||
|
|
||||||
@@unique([bucket, objektSchluessel])
|
@@unique([bucket, objektSchluessel])
|
||||||
@@index([organisationId, entitaet, entitaetId])
|
@@index([organisationId, entitaet, entitaetId])
|
||||||
@@index([organisationId, kategorie])
|
@@index([organisationId, kategorie])
|
||||||
@@ -3601,7 +3625,11 @@ model Belegentwurf {
|
|||||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
geloeschtAm DateTime? @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)
|
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)
|
fotoDatei Datei @relation("BelegentwurfFoto", fields: [fotoDateiId], references: [id], onDelete: Restrict)
|
||||||
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
|
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
|
||||||
erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
|
erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
|
||||||
@@ -3611,3 +3639,435 @@ model Belegentwurf {
|
|||||||
@@index([organisationId, belegdatum])
|
@@index([organisationId, belegdatum])
|
||||||
@@map("belegentwurf")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
|
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
|
||||||
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
|
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
|
||||||
* Atemschutz-Merkblatt, Schlauchpflege und Alarmierungsordnung.
|
* 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,
|
* 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
|
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||||
@@ -36,11 +39,14 @@ import {
|
|||||||
Alarmierungsart,
|
Alarmierungsart,
|
||||||
AnwesenheitAnlass,
|
AnwesenheitAnlass,
|
||||||
AusweisTokenTyp,
|
AusweisTokenTyp,
|
||||||
|
BeitragStand,
|
||||||
|
BuchungsArt,
|
||||||
ChecklistenpunktTyp,
|
ChecklistenpunktTyp,
|
||||||
EinsatzFunktion,
|
EinsatzFunktion,
|
||||||
EinsatzKategorie,
|
EinsatzKategorie,
|
||||||
EinsatzStatus,
|
EinsatzStatus,
|
||||||
Dienstgrad,
|
Dienstgrad,
|
||||||
|
KontoArt,
|
||||||
DruckArt,
|
DruckArt,
|
||||||
EreignisArt,
|
EreignisArt,
|
||||||
FahrtZweck,
|
FahrtZweck,
|
||||||
@@ -58,6 +64,8 @@ import {
|
|||||||
PruefungStatus,
|
PruefungStatus,
|
||||||
QualifikationsArt,
|
QualifikationsArt,
|
||||||
RolleSchluessel,
|
RolleSchluessel,
|
||||||
|
RechnungsAnlass,
|
||||||
|
RechnungStand,
|
||||||
SignaturRolle,
|
SignaturRolle,
|
||||||
HydrantStatus,
|
HydrantStatus,
|
||||||
HydrantTyp,
|
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
|
// Ablauf
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -3293,6 +3943,9 @@ async function main(): Promise<void> {
|
|||||||
// Phase 8 — Lokale KI
|
// Phase 8 — Lokale KI
|
||||||
await wikiAnlegen(organisation.id);
|
await wikiAnlegen(organisation.id);
|
||||||
|
|
||||||
|
// Phase 9 — Kassenverwaltung
|
||||||
|
await kassendatenAnlegen(organisation.id);
|
||||||
|
|
||||||
log("Fertig.");
|
log("Fertig.");
|
||||||
log("");
|
log("");
|
||||||
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
|
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
|
||||||
|
|||||||
@@ -256,6 +256,38 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
beschreibung: "Eine Wiki-Seite mit Fundstelle und Verlauf",
|
beschreibung: "Eine Wiki-Seite mit Fundstelle und Verlauf",
|
||||||
angemeldet: true,
|
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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Image from "next/image";
|
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 { KiStand } from "@prisma/client";
|
||||||
import type { inferRouterOutputs } from "@trpc/server";
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
import { trpc } from "@/lib/trpc/client";
|
import { trpc } from "@/lib/trpc/client";
|
||||||
@@ -12,6 +12,21 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||||
import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung";
|
import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung";
|
||||||
import { dateiHochladen } from "@/lib/hochladen";
|
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,
|
* Feld ist eine Aufforderung; ein geratener Wert wäre eine falsche Buchung,
|
||||||
* die niemand bemerkt.
|
* die niemand bemerkt.
|
||||||
*/
|
*/
|
||||||
export function BelegListe() {
|
export function BelegListe({ darfBuchen }: { darfBuchen: boolean }) {
|
||||||
const werkzeuge = trpc.useUtils();
|
const werkzeuge = trpc.useUtils();
|
||||||
|
|
||||||
|
const [buchen, setBuchen] = React.useState<Beleg | null>(null);
|
||||||
|
|
||||||
const [laedtHoch, setLaedtHoch] = React.useState(false);
|
const [laedtHoch, setLaedtHoch] = React.useState(false);
|
||||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||||
const [hinweis, setHinweis] = 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">Kategorie</th>
|
||||||
<th className="py-2 pr-4 text-right">Betrag</th>
|
<th className="py-2 pr-4 text-right">Betrag</th>
|
||||||
<th className="py-2 pr-4">Stand</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -158,11 +176,23 @@ export function BelegListe() {
|
|||||||
{KI_STAND_TEXT[beleg.stand]}
|
{KI_STAND_TEXT[beleg.stand]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2 pr-4">
|
||||||
{beleg.bestaetigtVon
|
{beleg.bestaetigtVon
|
||||||
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
|
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
|
||||||
: "—"}
|
: "—"}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -170,6 +200,19 @@ export function BelegListe() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -327,3 +370,135 @@ function BelegKarte({ beleg, fehler }: { beleg: Beleg; fehler: (nachricht: strin
|
|||||||
</Card>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export const metadata = { title: "Belege" };
|
|||||||
/**
|
/**
|
||||||
* Belegerfassung.
|
* Belegerfassung.
|
||||||
*
|
*
|
||||||
* Quittung fotografieren, Angaben prüfen, gegenzeichnen. Die Buchung daraus
|
* Quittung fotografieren, Angaben prüfen, gegenzeichnen — und seit Phase 9
|
||||||
* kommt mit der Kasse in Phase 9 — hier entsteht erst einmal ein sauber
|
* buchen. Der letzte Schritt braucht ein eigenes Recht: wer Belege erfasst,
|
||||||
* erfasster Beleg mit Foto.
|
* führt deshalb noch lange nicht die Kasse.
|
||||||
*/
|
*/
|
||||||
export default async function BelegeSeite() {
|
export default async function BelegeSeite() {
|
||||||
const sitzung = await aktuelleSitzung();
|
const sitzung = await aktuelleSitzung();
|
||||||
@@ -22,11 +22,11 @@ export default async function BelegeSeite() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BelegListe />
|
<BelegListe darfBuchen={hatRecht(sitzung, "kasse.bearbeiten")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -178,7 +178,7 @@ const EINTRAEGE: Eintrag[] = [
|
|||||||
|
|
||||||
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||||
const AKTUELLE_PHASE = 8;
|
const AKTUELLE_PHASE = 9;
|
||||||
|
|
||||||
export interface SeitenleisteProps {
|
export interface SeitenleisteProps {
|
||||||
rechte: Recht[];
|
rechte: Recht[];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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} €`;
|
||||||
|
}
|
||||||
@@ -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)}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
@@ -30,6 +30,10 @@ export const AUFTRAG = {
|
|||||||
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
|
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
|
||||||
/** Erzeugt das Atemschutzprotokoll als PDF (Phase 5). */
|
/** Erzeugt das Atemschutzprotokoll als PDF (Phase 5). */
|
||||||
ATEMSCHUTZPROTOKOLL_ERZEUGEN: "atemschutzprotokoll.erzeugen",
|
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). */
|
/** Bettet eine Wiki-Seite für die semantische Suche ein (Phase 8). */
|
||||||
WIKI_EINBETTEN: "wiki.einbetten",
|
WIKI_EINBETTEN: "wiki.einbetten",
|
||||||
/** Wandelt eine Diktataufnahme in Text (Phase 8). */
|
/** Wandelt eine Diktataufnahme in Text (Phase 8). */
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ import { benachrichtigungRouter } from "@/server/routers/termin/benachrichtigung
|
|||||||
import { dienstausweisRouter } from "@/server/routers/stammdaten/dienstausweis";
|
import { dienstausweisRouter } from "@/server/routers/stammdaten/dienstausweis";
|
||||||
import { wikiRouter } from "@/server/routers/ki/wiki";
|
import { wikiRouter } from "@/server/routers/ki/wiki";
|
||||||
import { assistentRouter } from "@/server/routers/ki/assistent";
|
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.
|
* Wurzel-Router.
|
||||||
@@ -195,6 +200,14 @@ export const appRouter = router({
|
|||||||
// Phase 8 — Lokale KI
|
// Phase 8 — Lokale KI
|
||||||
wiki: wikiRouter,
|
wiki: wikiRouter,
|
||||||
assistent: assistentRouter,
|
assistent: assistentRouter,
|
||||||
|
|
||||||
|
// Phase 9 — Kassenverwaltung
|
||||||
|
kassenkonto: kassenkontoRouter,
|
||||||
|
buchungskategorie: buchungskategorieRouter,
|
||||||
|
buchung: buchungRouter,
|
||||||
|
beitrag: beitragRouter,
|
||||||
|
rechnung: rechnungRouter,
|
||||||
|
kassenabschluss: abschlussRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -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.",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
zustellen,
|
zustellen,
|
||||||
} from "@/worker/benachrichtigung";
|
} from "@/worker/benachrichtigung";
|
||||||
import { diktatErkennen, veralteteSeitenEinbetten, wikiEinbetten } from "@/worker/ki";
|
import { diktatErkennen, veralteteSeitenEinbetten, wikiEinbetten } from "@/worker/ki";
|
||||||
|
import { pruefberichtErzeugen, rechnungErzeugen, ueberfaelligeMelden } from "@/worker/kasse";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hintergrundprozess.
|
* Hintergrundprozess.
|
||||||
@@ -195,6 +196,40 @@ async function diktatErkennenAuftrag(auftrag: Job<AuftragsDaten>) {
|
|||||||
return diktatErkennen(organisationId, diktatId);
|
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 }> {
|
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
|
||||||
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
|
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);
|
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,
|
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
|
||||||
// ausgetretene Mitglieder nach PERSON_ANONYMISIERUNG_TAGE anonymisieren.
|
// ausgetretene Mitglieder nach PERSON_ANONYMISIERUNG_TAGE anonymisieren.
|
||||||
return { entfernt: benachrichtigungen.entfernt };
|
return { entfernt: benachrichtigungen.entfernt };
|
||||||
@@ -224,6 +267,8 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
|
|||||||
[AUFTRAG.BENACHRICHTIGUNG_ERINNERN]: benachrichtigungErinnern,
|
[AUFTRAG.BENACHRICHTIGUNG_ERINNERN]: benachrichtigungErinnern,
|
||||||
[AUFTRAG.WIKI_EINBETTEN]: wikiEinbettenAuftrag,
|
[AUFTRAG.WIKI_EINBETTEN]: wikiEinbettenAuftrag,
|
||||||
[AUFTRAG.DIKTAT_ERKENNEN]: diktatErkennenAuftrag,
|
[AUFTRAG.DIKTAT_ERKENNEN]: diktatErkennenAuftrag,
|
||||||
|
[AUFTRAG.RECHNUNG_ERZEUGEN]: rechnungErzeugenAuftrag,
|
||||||
|
[AUFTRAG.KASSENPRUEFBERICHT_ERZEUGEN]: kassenpruefberichtAuftrag,
|
||||||
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
|
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -67,9 +67,7 @@ test.describe("Anmeldung", () => {
|
|||||||
await expect(page.getByRole("note")).toContainText("analoge Überwachung");
|
await expect(page.getByRole("note")).toContainText("analoge Überwachung");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("zeigt die Navigation und markiert künftige Module als noch nicht verfügbar", async ({
|
test("zeigt die Navigation mit allen umgesetzten Modulen", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/anmelden");
|
await page.goto("/anmelden");
|
||||||
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||||
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||||
@@ -95,13 +93,13 @@ test.describe("Anmeldung", () => {
|
|||||||
navigation.getByRole("link", { name: "Benachrichtigungen" }),
|
navigation.getByRole("link", { name: "Benachrichtigungen" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// Die Kasse kommt erst in Phase 9 — der Eintrag ist sichtbar, aber kein
|
// Die Kasse ist seit Phase 9 ein Verweis.
|
||||||
// Verweis. Beim Abschluss einer Phase wandert diese Zusicherung eine Zeile
|
//
|
||||||
// weiter nach unten.
|
// Bis dahin stand hier die Gegenprobe: der Eintrag sichtbar, aber
|
||||||
await expect(navigation.getByText("Kasse")).toBeVisible();
|
// ausgegraut, weil das Modul noch fehlte. Mit Phase 9 gibt es kein
|
||||||
await expect(navigation.getByRole("link", { name: "Kasse" })).toHaveCount(
|
// künftiges Modul mehr — eine Zusicherung über ein Modul, das es nicht
|
||||||
0,
|
// gibt, wäre keine. Kommt später eines dazu, gehört sie hierher zurück.
|
||||||
);
|
await expect(navigation.getByRole("link", { name: "Kasse" })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||