Phase 9: Kassenverwaltung

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

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

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

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

562 Unit-Tests und 239 E2E-Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 19:23:34 +02:00
co-authored by Claude Opus 5
parent d70acad4cd
commit 4165334c5a
78 changed files with 12314 additions and 42 deletions
@@ -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();