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
@@ -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();