Phase 9: Kassenverwaltung (#14)
This commit was merged in pull request #14.
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user