Phase 9: Kassenverwaltung (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-09-05 02:30:51 +00:00
parent d70acad4cd
commit 2b53556175
78 changed files with 12314 additions and 42 deletions
+460
View File
@@ -234,6 +234,17 @@ model Organisation {
diktate Diktat[]
belegentwuerfe Belegentwurf[]
// Phase 9 — Kassenverwaltung
kassenkonten Kassenkonto[]
buchungskategorien Buchungskategorie[]
buchungen Buchung[]
beitragssaetze Beitragssatz[]
beitraege Beitrag[]
rechnungen Rechnung[]
rechnungspositionen Rechnungsposition[]
kassenabschluesse Kassenabschluss[]
abschlussposten Abschlussposten[]
@@index([aktiv])
@@map("organisation")
}
@@ -356,6 +367,14 @@ model Person {
erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser")
bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger")
// Phase 9 — Kassenverwaltung
buchungen Buchung[] @relation("BuchungPerson")
erfassteBuchungen Buchung[] @relation("BuchungErfasser")
beitraege Beitrag[] @relation("BeitragPerson")
gepruefteAbschluesse Kassenabschluss[] @relation("AbschlussPruefer")
zweitgepruefteAbschluesse Kassenabschluss[] @relation("AbschlussZweitpruefer")
erstellteRechnungen Rechnung[] @relation("RechnungErsteller")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@ -602,6 +621,11 @@ model Datei {
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
// Phase 9
alsBuchungsbeleg Buchung[] @relation("BuchungBeleg")
alsRechnungsPdf Rechnung[] @relation("RechnungPdf")
alsKassenpruefbericht Kassenabschluss[] @relation("AbschlussBericht")
@@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie])
@@ -3601,7 +3625,11 @@ model Belegentwurf {
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
/// Die Buchung, die daraus geworden ist (Phase 9). Leer heißt: noch keine.
buchungId String? @db.Uuid
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
fotoDatei Datei @relation("BelegentwurfFoto", fields: [fotoDateiId], references: [id], onDelete: Restrict)
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
@@ -3611,3 +3639,435 @@ model Belegentwurf {
@@index([organisationId, belegdatum])
@@map("belegentwurf")
}
// =============================================================================
// Phase 9 — Kassenverwaltung
//
// Drei Festlegungen bestimmen alles Weitere. Sie stehen hier, weil sie sich
// im Schema abbilden und nicht bloß in der Anwendung durchgesetzt werden:
//
// 1. **Beträge sind DECIMAL(12,2), niemals Fließkomma.** 0,1 + 0,2 ist in
// binärer Fließkommadarstellung nicht 0,3, und eine Kasse, die sich um
// Cent verrechnet, ist keine Kasse. Zwölf Stellen reichen für zehn
// Milliarden — mehr als jede Wehr je bewegt.
//
// 2. **Eine festgeschriebene Buchung ist unveränderbar.** Korrekturen laufen
// über eine Gegenbuchung mit Verweis auf das Original. Das ist keine
// Förmlichkeit: ein Kassenbuch, in dem man rückwirkend ändern kann, ist
// vor der Kassenprüfung wertlos. Der Trigger in der Migration setzt es
// durch.
//
// 3. **Der Kassenabschluss friert seinen Zeitraum ein.** Danach nimmt die
// Buchungsmaske kein Datum daraus mehr an. Sonst hätte der geprüfte
// Bestand am Tag nach der Prüfung eine andere Zahl.
//
// Der Betrag ist immer **positiv**; die Richtung steht in `art`. Ein
// vorzeichenbehafteter Betrag wäre kürzer, aber jede Auswertung müsste dann
// darauf vertrauen, dass das Vorzeichen zur Kategorie passt — und beim ersten
// Tippfehler stimmt die Summe nicht mehr.
// =============================================================================
/// Welche Art von Konto.
enum KontoArt {
/// Die Barkasse im Gerätehaus. Wird bei der Prüfung gezählt.
BAR
/// Ein Bankkonto. Wird gegen den Auszug geprüft.
BANK
/// Rücklage für eine bestimmte Anschaffung — getrennt geführt, damit
/// niemand versehentlich das Geld für das neue Fahrzeug verbraucht.
RUECKLAGE
}
/// Geht Geld ein oder hinaus.
enum BuchungsArt {
EINNAHME
AUSGABE
}
/// Wie es um einen Beitrag steht.
enum BeitragStand {
OFFEN
BEZAHLT
/// Erlassen — bei Härtefällen, Ehrenmitgliedern, Jugendlichen.
ERLASSEN
GEMAHNT
}
/// Wie es um eine Rechnung steht.
enum RechnungStand {
ENTWURF
VERSENDET
BEZAHLT
GEMAHNT
STORNIERT
}
/// Woraus eine Rechnung an Dritte entsteht.
enum RechnungsAnlass {
/// Böswilliger oder fahrlässig ausgelöster Fehlalarm.
FEHLALARM
/// Ölspur, auslaufende Betriebsstoffe — nach Verursacherprinzip.
OELEINSATZ
/// Absicherung, Brandsicherheitswache bei einer Veranstaltung.
VERANSTALTUNG
/// Vermietung von Gerät oder Räumen.
VERMIETUNG
SONSTIGES
}
/// Ein Konto der Wehr.
model Kassenkonto {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bezeichnung String @db.VarChar(120)
art KontoArt @default(BAR)
/// Nur bei Bankkonten. Wird in Auswertungen nie vollständig angezeigt.
iban String? @db.VarChar(34)
bic String? @db.VarChar(11)
/// Der Bestand zu Beginn der Aufzeichnung. Alles Weitere ergibt sich aus
/// den Buchungen — er wird nie nachträglich „korrigiert".
anfangsbestand Decimal @default(0) @db.Decimal(12, 2)
bemerkung String? @db.VarChar(500)
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
buchungen Buchung[]
posten Abschlussposten[]
@@unique([organisationId, bezeichnung])
@@index([organisationId, aktiv])
@@map("kassenkonto")
}
/// Wofür Geld ausgegeben oder eingenommen wurde.
///
/// Zweistufig: eine Oberkategorie („Fahrzeuge") mit Unterkategorien
/// („Kraftstoff", „Reparatur"). Mehr Ebenen braucht eine Wehr nicht, und jede
/// weitere macht die Auswertung unlesbar.
model Buchungskategorie {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(120)
art BuchungsArt
/// Die Oberkategorie. Leer heißt: das hier ist eine.
oberkategorieId String? @db.Uuid
/// Nummer im Kontenrahmen der Gemeinde — für den Export an die Kämmerei.
kontonummer String? @db.VarChar(20)
bemerkung String? @db.VarChar(500)
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
oberkategorie Buchungskategorie? @relation("Kategoriebaum", fields: [oberkategorieId], references: [id], onDelete: SetNull)
unterkategorien Buchungskategorie[] @relation("Kategoriebaum")
buchungen Buchung[]
@@unique([organisationId, name])
@@index([organisationId, art])
@@map("buchungskategorie")
}
/// Eine Buchung.
///
/// Der Betrag ist positiv, die Richtung steht in `art`. Festgeschrieben heißt:
/// unveränderbar und nicht löschbar — die Datenbank hält dagegen. Wer sich
/// vertan hat, bucht dagegen; die Gegenbuchung verweist auf das Original, und
/// beide bleiben stehen.
model Buchung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
kontoId String @db.Uuid
kategorieId String @db.Uuid
/// Das Buchungsdatum, nicht der Zeitpunkt der Erfassung. Danach richtet
/// sich, in welchen Abschluss die Buchung fällt.
datum DateTime @db.Date
art BuchungsArt
betrag Decimal @db.Decimal(12, 2)
verwendungszweck String @db.VarChar(300)
belegnummer String? @db.VarChar(60)
/// Wer das Geld bekommen oder gegeben hat. Entweder eine Person der Wehr
/// **oder** ein Dritter als Text — nie beides.
personId String? @db.Uuid
lieferant String? @db.VarChar(200)
/// Bei einer Umbuchung zwischen zwei eigenen Konten das Gegenkonto.
gegenkontoId String? @db.Uuid
belegDateiId String? @db.Uuid
/// Festgeschrieben: ab hier unveränderbar.
festgeschriebenAm DateTime? @db.Timestamptz(3)
/// Die Buchung, die diese hier aufhebt — und umgekehrt.
storniertVonId String? @db.Uuid
erfasstVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
konto Kassenkonto @relation(fields: [kontoId], references: [id], onDelete: Restrict)
kategorie Buchungskategorie @relation(fields: [kategorieId], references: [id], onDelete: Restrict)
person Person? @relation("BuchungPerson", fields: [personId], references: [id], onDelete: SetNull)
erfasstVon Person? @relation("BuchungErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
belegDatei Datei? @relation("BuchungBeleg", fields: [belegDateiId], references: [id], onDelete: SetNull)
storniertVon Buchung? @relation("Stornierung", fields: [storniertVonId], references: [id], onDelete: SetNull)
storniert Buchung[] @relation("Stornierung")
beitraege Beitrag[]
rechnungen Rechnung[]
belegentwurf Belegentwurf[]
@@index([organisationId, datum(sort: Desc)])
@@index([organisationId, kontoId, datum])
@@index([organisationId, kategorieId])
@@map("buchung")
}
/// Was ein Mitglied im Jahr zahlt.
///
/// Je Abteilung ein Satz: Aktive zahlen anderes als Jugend, Ehrenmitglieder
/// oft nichts. Der Satz gilt ab einem Jahr und wird nicht rückwirkend
/// geändert — sonst stimmen die Beiträge vergangener Jahre nicht mehr.
model Beitragssatz {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bezeichnung String @db.VarChar(120)
betrag Decimal @db.Decimal(12, 2)
/// Für welche Abteilung. Leer heißt: für alle, die keinen eigenen Satz haben.
abteilung String? @db.VarChar(60)
/// Ab welchem Jahr er gilt.
abJahr Int
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
beitraege Beitrag[]
@@unique([organisationId, abteilung, abJahr])
@@index([organisationId, abJahr])
@@map("beitragssatz")
}
/// Der Beitrag einer Person für ein Jahr.
model Beitrag {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
jahr Int
satzId String? @db.Uuid
betrag Decimal @db.Decimal(12, 2)
stand BeitragStand @default(OFFEN)
/// Die Buchung, mit der bezahlt wurde. Erst sie macht aus „bezahlt" eine
/// Zahl in der Kasse.
buchungId String? @db.Uuid
bezahltAm DateTime? @db.Date
/// Wie oft gemahnt wurde. Null heißt: noch nicht.
mahnstufe Int @default(0)
gemahntAm DateTime? @db.Date
/// Bei Erlass: warum. Ein erlassener Beitrag ohne Begründung ist in der
/// Kassenprüfung eine offene Frage.
erlassGrund String? @db.VarChar(300)
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("BeitragPerson", fields: [personId], references: [id], onDelete: Cascade)
satz Beitragssatz? @relation(fields: [satzId], references: [id], onDelete: SetNull)
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
@@unique([personId, jahr])
@@index([organisationId, jahr, stand])
@@map("beitrag")
}
/// Eine Rechnung an Dritte.
///
/// Fehlalarm, Ölspur, Absicherung einer Veranstaltung. Nummer aus dem
/// Nummernkreis (`JJJJ-NNNN`), PDF über Gotenberg.
model Rechnung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
nummer String @db.VarChar(20)
anlass RechnungsAnlass @default(SONSTIGES)
stand RechnungStand @default(ENTWURF)
/// An wen. Bewusst als Text und nicht als Verweis: der Empfänger ist in
/// aller Regel niemand, den die Wehr dauerhaft führt.
empfaenger String @db.VarChar(200)
empfaengerZusatz String? @db.VarChar(200)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
/// Worauf sie sich bezieht.
einsatzId String? @db.Uuid
datum DateTime @db.Date
faelligAm DateTime @db.Date
/// Summe der Positionen. Wird beim Speichern gerechnet, nicht eingegeben.
betrag Decimal @db.Decimal(12, 2)
bemerkung String? @db.VarChar(1000)
/// Die Buchung des Zahlungseingangs.
buchungId String? @db.Uuid
bezahltAm DateTime? @db.Date
gemahntAm DateTime? @db.Date
mahnstufe Int @default(0)
pdfDateiId String? @db.Uuid
erstelltVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
pdfDatei Datei? @relation("RechnungPdf", fields: [pdfDateiId], references: [id], onDelete: SetNull)
erstelltVon Person? @relation("RechnungErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
positionen Rechnungsposition[]
@@unique([organisationId, nummer])
@@index([organisationId, stand])
@@index([organisationId, datum(sort: Desc)])
@@map("rechnung")
}
/// Eine Zeile auf einer Rechnung.
model Rechnungsposition {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
rechnungId String @db.Uuid
reihenfolge Int
bezeichnung String @db.VarChar(300)
/// Menge mit drei Nachkommastellen — Einsatzstunden werden viertelstündlich
/// abgerechnet, und 0,25 muss genau sein.
menge Decimal @default(1) @db.Decimal(12, 3)
einheit String? @db.VarChar(20)
einzelpreis Decimal @db.Decimal(12, 2)
/// Menge × Einzelpreis, beim Speichern gerechnet.
gesamtpreis Decimal @db.Decimal(12, 2)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
rechnung Rechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
@@unique([rechnungId, reihenfolge])
@@index([organisationId])
@@map("rechnungsposition")
}
/// Ein Kassenabschluss.
///
/// Der Vorgang, für den es die ganze Kasse gibt: zwei Kassenprüfer setzen sich
/// hin, zählen das Bargeld, vergleichen die Kontoauszüge und unterschreiben.
/// Danach ist der Zeitraum zu — die Buchungsmaske nimmt kein Datum daraus mehr
/// an.
///
/// Zwei Prüfer, weil es die Satzung fast jeder Wehr so vorsieht. Der zweite
/// ist trotzdem optional: eine Wehr, die gerade nur einen hat, soll deshalb
/// nicht ohne Abschluss dastehen.
model Kassenabschluss {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
vonAm DateTime @db.Date
bisAm DateTime @db.Date
/// Summe über alle Konten, aus den Buchungen gerechnet.
bestandLautBuch Decimal @db.Decimal(12, 2)
/// Was tatsächlich gezählt wurde.
gezaehlterBestand Decimal @db.Decimal(12, 2)
/// gezaehlt minus laut Buch. Positiv heißt: es war mehr da.
differenz Decimal @db.Decimal(12, 2)
/// Eine Differenz ohne Erklärung ist der Punkt, an dem eine Kassenprüfung
/// hängen bleibt.
erlaeuterung String? @db.VarChar(2000)
prueferId String? @db.Uuid
zweiterPrueferId String? @db.Uuid
geprueftAm DateTime? @db.Date
berichtDateiId String? @db.Uuid
/// Festgeschrieben: ab hier ist der Zeitraum zu.
festgeschriebenAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
pruefer Person? @relation("AbschlussPruefer", fields: [prueferId], references: [id], onDelete: SetNull)
zweiterPruefer Person? @relation("AbschlussZweitpruefer", fields: [zweiterPrueferId], references: [id], onDelete: SetNull)
berichtDatei Datei? @relation("AbschlussBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull)
posten Abschlussposten[]
@@index([organisationId, bisAm(sort: Desc)])
@@map("kassenabschluss")
}
/// Was auf einem Konto zum Abschluss stand.
///
/// Je Konto eine Zeile: der Kassenprüfer zählt das Bargeld und liest den
/// Kontostand vom Auszug ab. Eine einzelne Summe über alles würde verbergen,
/// auf welchem Konto die Differenz sitzt.
model Abschlussposten {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
abschlussId String @db.Uuid
kontoId String @db.Uuid
bestandLautBuch Decimal @db.Decimal(12, 2)
gezaehlterBestand Decimal @db.Decimal(12, 2)
differenz Decimal @db.Decimal(12, 2)
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
abschluss Kassenabschluss @relation(fields: [abschlussId], references: [id], onDelete: Cascade)
konto Kassenkonto @relation(fields: [kontoId], references: [id], onDelete: Restrict)
@@unique([abschlussId, kontoId])
@@index([organisationId])
@@map("abschlussposten")
}