307 lines
13 KiB
PL/PgSQL
307 lines
13 KiB
PL/PgSQL
-- =============================================================================
|
|
-- 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();
|