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:
co-authored by
Claude Opus 5
parent
d70acad4cd
commit
4165334c5a
@@ -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();
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+653
@@ -21,6 +21,9 @@
|
||||
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
|
||||
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
|
||||
* Atemschutz-Merkblatt, Schlauchpflege und Alarmierungsordnung.
|
||||
* Ab Phase 9 dazu: das Kassenjahr — drei Konten, der Standardkontenrahmen,
|
||||
* gut dreißig Buchungen über zwei Jahre, die Mitgliedsbeiträge mit Rückständen,
|
||||
* zwei Rechnungen und die festgeschriebene Kassenprüfung des Vorjahres.
|
||||
*
|
||||
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
||||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||
@@ -36,11 +39,14 @@ import {
|
||||
Alarmierungsart,
|
||||
AnwesenheitAnlass,
|
||||
AusweisTokenTyp,
|
||||
BeitragStand,
|
||||
BuchungsArt,
|
||||
ChecklistenpunktTyp,
|
||||
EinsatzFunktion,
|
||||
EinsatzKategorie,
|
||||
EinsatzStatus,
|
||||
Dienstgrad,
|
||||
KontoArt,
|
||||
DruckArt,
|
||||
EreignisArt,
|
||||
FahrtZweck,
|
||||
@@ -58,6 +64,8 @@ import {
|
||||
PruefungStatus,
|
||||
QualifikationsArt,
|
||||
RolleSchluessel,
|
||||
RechnungsAnlass,
|
||||
RechnungStand,
|
||||
SignaturRolle,
|
||||
HydrantStatus,
|
||||
HydrantTyp,
|
||||
@@ -3247,6 +3255,648 @@ async function wikiAnlegen(organisationId: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Phase 9 — Kassenverwaltung
|
||||
//
|
||||
// Ein Kassenjahr, wie es eine Wehr wirklich hat: drei Konten, der
|
||||
// Standardkontenrahmen, gut dreißig Buchungen über zwei Jahre, die
|
||||
// Mitgliedsbeiträge, zwei Rechnungen und die abgeschlossene Kassenprüfung
|
||||
// des Vorjahres.
|
||||
//
|
||||
// Die Reihenfolge ist hier nicht beliebig, und das ist kein Zufall des
|
||||
// Skripts, sondern die Absicherung selbst: sobald der Abschluss für das
|
||||
// Vorjahr festgeschrieben ist, lehnt ein Trigger jede weitere Buchung mit
|
||||
// einem Datum in diesem Zeitraum ab. Erst die Buchungen, dann der Abschluss —
|
||||
// wer es umdreht, bekommt vom Seed dieselbe Fehlermeldung wie der Kassier,
|
||||
// der drei Wochen nach der Prüfung noch eine Quittung findet.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function kassendatenAnlegen(organisationId: string): Promise<void> {
|
||||
const vorhanden = await prisma.kassenkonto.count({ where: { organisationId } });
|
||||
if (vorhanden > 0) {
|
||||
log("Kassendaten bereits vorhanden — übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { STANDARDKATEGORIEN } = await import("../src/lib/kasse/vorschlag");
|
||||
|
||||
const jahr = new Date().getUTCFullYear();
|
||||
const vorjahr = jahr - 1;
|
||||
|
||||
// --- Konten ---------------------------------------------------------------
|
||||
|
||||
const bar = await prisma.kassenkonto.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bezeichnung: "Barkasse",
|
||||
art: KontoArt.BAR,
|
||||
anfangsbestand: "250.00",
|
||||
bemerkung: "Kassette im Kommandantenzimmer. Wechselgeld für Feste.",
|
||||
},
|
||||
});
|
||||
|
||||
const giro = await prisma.kassenkonto.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bezeichnung: "Girokonto Raiffeisenbank",
|
||||
art: KontoArt.BANK,
|
||||
iban: "DE02701500000000594937",
|
||||
bic: "SSKMDEMM",
|
||||
anfangsbestand: "4800.00",
|
||||
},
|
||||
});
|
||||
|
||||
const ruecklage = await prisma.kassenkonto.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bezeichnung: "Rücklage Fahrzeugbeschaffung",
|
||||
art: KontoArt.RUECKLAGE,
|
||||
iban: "DE02120300000000202051",
|
||||
anfangsbestand: "12000.00",
|
||||
bemerkung: "Angespart für den Ersatz des MTW. Wird nicht laufend bewegt.",
|
||||
},
|
||||
});
|
||||
|
||||
// --- Kontenrahmen ---------------------------------------------------------
|
||||
//
|
||||
// Zwei Durchgänge: erst die Oberkategorien, dann die Unterkategorien mit
|
||||
// ihrem Verweis. Anders ginge es nicht, der Baum verweist auf sich selbst.
|
||||
|
||||
const kategorien = new Map<string, string>();
|
||||
|
||||
for (const eintrag of STANDARDKATEGORIEN.filter((k) => !k.oberkategorie)) {
|
||||
const angelegt = await prisma.buchungskategorie.create({
|
||||
data: { organisationId, name: eintrag.name, art: eintrag.art },
|
||||
});
|
||||
kategorien.set(eintrag.name, angelegt.id);
|
||||
}
|
||||
|
||||
for (const eintrag of STANDARDKATEGORIEN.filter((k) => k.oberkategorie)) {
|
||||
const angelegt = await prisma.buchungskategorie.create({
|
||||
data: {
|
||||
organisationId,
|
||||
name: eintrag.name,
|
||||
art: eintrag.art,
|
||||
oberkategorieId: kategorien.get(eintrag.oberkategorie!) ?? null,
|
||||
},
|
||||
});
|
||||
kategorien.set(eintrag.name, angelegt.id);
|
||||
}
|
||||
|
||||
const kategorie = (name: string): string => {
|
||||
const id = kategorien.get(name);
|
||||
if (!id) throw new Error(`Kategorie fehlt im Standardkontenrahmen: ${name}`);
|
||||
return id;
|
||||
};
|
||||
|
||||
const kassier = await prisma.person.findFirst({
|
||||
where: {
|
||||
organisationId,
|
||||
rollen: { some: { rolle: { schluessel: RolleSchluessel.KASSIER } } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// --- Buchungen ------------------------------------------------------------
|
||||
//
|
||||
// Das Vorjahr vollständig und festgeschrieben, das laufende Jahr bis in den
|
||||
// Sommer und noch offen. So zeigt die Ansicht beide Zustände: das
|
||||
// geschlossene Buch und das, in dem noch gearbeitet wird.
|
||||
|
||||
type Zeile = {
|
||||
konto: string;
|
||||
monat: number;
|
||||
tag: number;
|
||||
art: BuchungsArt;
|
||||
betrag: string;
|
||||
kategorie: string;
|
||||
zweck: string;
|
||||
gegenueber?: string;
|
||||
beleg?: string;
|
||||
};
|
||||
|
||||
const vorjahrszeilen: Zeile[] = [
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 1,
|
||||
tag: 15,
|
||||
art: BuchungsArt.EINNAHME,
|
||||
betrag: "6500.00",
|
||||
kategorie: "Zuschüsse",
|
||||
zweck: "Gemeindezuschuss laufender Betrieb",
|
||||
gegenueber: "Gemeinde Musterhausen",
|
||||
beleg: "Z-2024-01",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 2,
|
||||
tag: 3,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "1284.50",
|
||||
kategorie: "Versicherungen",
|
||||
zweck: "Jahresprämie Fahrzeug- und Unfallversicherung",
|
||||
gegenueber: "Bayerische Versicherungskammer",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 3,
|
||||
tag: 12,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "412.90",
|
||||
kategorie: "Kraftstoff",
|
||||
zweck: "Diesel LF 20, Sammelrechnung Februar",
|
||||
gegenueber: "Tankstelle Musterhausen",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 4,
|
||||
tag: 8,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "2340.00",
|
||||
kategorie: "Atemschutz",
|
||||
zweck: "Zwei Pressluftatmer, Ersatzbeschaffung",
|
||||
gegenueber: "Dräger Safety",
|
||||
beleg: "RE-2024-114",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 5,
|
||||
tag: 22,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "868.00",
|
||||
kategorie: "Prüfungen",
|
||||
zweck: "Wiederkehrende Prüfung Drehleiter",
|
||||
gegenueber: "TÜV Süd",
|
||||
},
|
||||
{
|
||||
konto: bar.id,
|
||||
monat: 6,
|
||||
tag: 15,
|
||||
art: BuchungsArt.EINNAHME,
|
||||
betrag: "3860.00",
|
||||
kategorie: "Veranstaltungen",
|
||||
zweck: "Maifest, Getränke- und Speisenverkauf",
|
||||
},
|
||||
{
|
||||
konto: bar.id,
|
||||
monat: 6,
|
||||
tag: 15,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "1420.60",
|
||||
kategorie: "Verpflegung",
|
||||
zweck: "Maifest, Einkauf Getränke und Fleisch",
|
||||
gegenueber: "Getränke Hofmann",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 7,
|
||||
tag: 2,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "1150.00",
|
||||
kategorie: "Schutzkleidung",
|
||||
zweck: "Vier Einsatzjacken, Ersatz nach Verschleiß",
|
||||
gegenueber: "Feuerwehrbedarf Barth",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 9,
|
||||
tag: 18,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "624.00",
|
||||
kategorie: "Ausbildung",
|
||||
zweck: "Lehrgangsgebühren Maschinistenlehrgang",
|
||||
gegenueber: "Landkreis Musterkreis",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 10,
|
||||
tag: 30,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "398.20",
|
||||
kategorie: "Wasser und Strom",
|
||||
zweck: "Abschlagszahlung Strom Gerätehaus",
|
||||
gegenueber: "Stadtwerke Musterhausen",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 11,
|
||||
tag: 14,
|
||||
art: BuchungsArt.EINNAHME,
|
||||
betrag: "780.00",
|
||||
kategorie: "Spenden",
|
||||
zweck: "Spenden aus der Haussammlung",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 12,
|
||||
tag: 20,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "36.00",
|
||||
kategorie: "Bankgebühren",
|
||||
zweck: "Kontoführung viertes Quartal",
|
||||
},
|
||||
];
|
||||
|
||||
const laufendeZeilen: Zeile[] = [
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 1,
|
||||
tag: 20,
|
||||
art: BuchungsArt.EINNAHME,
|
||||
betrag: "6500.00",
|
||||
kategorie: "Zuschüsse",
|
||||
zweck: "Gemeindezuschuss laufender Betrieb",
|
||||
gegenueber: "Gemeinde Musterhausen",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 2,
|
||||
tag: 5,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "1341.80",
|
||||
kategorie: "Versicherungen",
|
||||
zweck: "Jahresprämie Fahrzeug- und Unfallversicherung",
|
||||
gegenueber: "Bayerische Versicherungskammer",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 2,
|
||||
tag: 28,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "487.40",
|
||||
kategorie: "Kraftstoff",
|
||||
zweck: "Diesel LF 20 und MTW, Sammelrechnung Februar",
|
||||
gegenueber: "Tankstelle Musterhausen",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 3,
|
||||
tag: 14,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "1890.00",
|
||||
kategorie: "Reparatur",
|
||||
zweck: "Kupplung MTW, Werkstattrechnung",
|
||||
gegenueber: "Werkstatt Müller GmbH",
|
||||
beleg: "RE-4471",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 4,
|
||||
tag: 3,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "212.00",
|
||||
kategorie: "Verwaltung",
|
||||
zweck: "Büromaterial und Druckerpatronen",
|
||||
gegenueber: "Schreibwaren Ludwig",
|
||||
},
|
||||
{
|
||||
konto: bar.id,
|
||||
monat: 5,
|
||||
tag: 1,
|
||||
art: BuchungsArt.EINNAHME,
|
||||
betrag: "4120.00",
|
||||
kategorie: "Veranstaltungen",
|
||||
zweck: "Maifest, Getränke- und Speisenverkauf",
|
||||
},
|
||||
{
|
||||
konto: bar.id,
|
||||
monat: 5,
|
||||
tag: 1,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "1588.30",
|
||||
kategorie: "Verpflegung",
|
||||
zweck: "Maifest, Einkauf Getränke und Fleisch",
|
||||
gegenueber: "Getränke Hofmann",
|
||||
},
|
||||
{
|
||||
konto: bar.id,
|
||||
monat: 5,
|
||||
tag: 6,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "2500.00",
|
||||
kategorie: "Verwaltung",
|
||||
zweck: "Einzahlung Barkasse auf das Girokonto nach dem Maifest",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 5,
|
||||
tag: 6,
|
||||
art: BuchungsArt.EINNAHME,
|
||||
betrag: "2500.00",
|
||||
kategorie: "Veranstaltungen",
|
||||
zweck: "Einzahlung aus der Barkasse nach dem Maifest",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 6,
|
||||
tag: 11,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "742.00",
|
||||
kategorie: "Prüfungen",
|
||||
zweck: "Schlauchprüfung, 40 Stück B und C",
|
||||
gegenueber: "Schlauchpflege Nordbayern",
|
||||
},
|
||||
{
|
||||
konto: giro.id,
|
||||
monat: 6,
|
||||
tag: 25,
|
||||
art: BuchungsArt.AUSGABE,
|
||||
betrag: "36.00",
|
||||
kategorie: "Bankgebühren",
|
||||
zweck: "Kontoführung zweites Quartal",
|
||||
},
|
||||
{
|
||||
konto: ruecklage.id,
|
||||
monat: 7,
|
||||
tag: 1,
|
||||
art: BuchungsArt.EINNAHME,
|
||||
betrag: "3000.00",
|
||||
kategorie: "Zuschüsse",
|
||||
zweck: "Jährliche Zuführung zur Fahrzeugrücklage",
|
||||
gegenueber: "Gemeinde Musterhausen",
|
||||
},
|
||||
];
|
||||
|
||||
async function buchungAnlegen(zeile: Zeile, festgeschrieben: boolean, buchungsjahr: number) {
|
||||
return prisma.buchung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
kontoId: zeile.konto,
|
||||
kategorieId: kategorie(zeile.kategorie),
|
||||
datum: datum(buchungsjahr, zeile.monat, zeile.tag),
|
||||
art: zeile.art,
|
||||
betrag: zeile.betrag,
|
||||
verwendungszweck: zeile.zweck,
|
||||
lieferant: zeile.gegenueber ?? null,
|
||||
belegnummer: zeile.beleg ?? null,
|
||||
erfasstVonId: kassier?.id ?? null,
|
||||
festgeschriebenAm: festgeschrieben ? datum(vorjahr + 1, 2, 10) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const zeile of vorjahrszeilen) {
|
||||
await buchungAnlegen(zeile, true, vorjahr);
|
||||
}
|
||||
for (const zeile of laufendeZeilen) {
|
||||
await buchungAnlegen(zeile, false, jahr);
|
||||
}
|
||||
|
||||
// --- Beiträge -------------------------------------------------------------
|
||||
|
||||
const satzAktive = await prisma.beitragssatz.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bezeichnung: "Aktive und Passive",
|
||||
betrag: "25.00",
|
||||
abteilung: null,
|
||||
abJahr: vorjahr,
|
||||
},
|
||||
});
|
||||
|
||||
const satzJugend = await prisma.beitragssatz.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bezeichnung: "Jugendfeuerwehr",
|
||||
betrag: "10.00",
|
||||
abteilung: "Jugendfeuerwehr",
|
||||
abJahr: vorjahr,
|
||||
},
|
||||
});
|
||||
|
||||
const mitglieder = await prisma.person.findMany({
|
||||
where: { organisationId, aktiv: true, austrittsdatum: null },
|
||||
orderBy: { mitgliedsnummer: "asc" },
|
||||
select: { id: true, abteilung: true },
|
||||
});
|
||||
|
||||
const beitragsKategorie = kategorie("Mitgliedsbeiträge");
|
||||
|
||||
// Die meisten haben bezahlt, zwei stehen offen, einer ist gemahnt, einem
|
||||
// wurde erlassen. Das ist die Verteilung, die eine Wehr im Herbst wirklich
|
||||
// vor sich hat — und der Grund, warum es die Rückstandsliste gibt.
|
||||
for (const [stelle, person] of mitglieder.entries()) {
|
||||
const jugend = person.abteilung === "Jugendfeuerwehr";
|
||||
const satz = jugend ? satzJugend : satzAktive;
|
||||
const betrag = jugend ? "10.00" : "25.00";
|
||||
|
||||
let buchungId: string | null = null;
|
||||
let stand: BeitragStand = BeitragStand.BEZAHLT;
|
||||
let bezahltAm: Date | null = datum(jahr, 2, 12 + (stelle % 10));
|
||||
let mahnstufe = 0;
|
||||
let gemahntAm: Date | null = null;
|
||||
let erlassGrund: string | null = null;
|
||||
|
||||
if (stelle % 7 === 3) {
|
||||
stand = BeitragStand.OFFEN;
|
||||
bezahltAm = null;
|
||||
} else if (stelle % 11 === 5) {
|
||||
stand = BeitragStand.GEMAHNT;
|
||||
bezahltAm = null;
|
||||
mahnstufe = 1;
|
||||
gemahntAm = datum(jahr, 6, 30);
|
||||
} else if (stelle % 13 === 9) {
|
||||
stand = BeitragStand.ERLASSEN;
|
||||
bezahltAm = null;
|
||||
erlassGrund = "Längere Erkrankung, Beschluss der Kommandantschaft vom 12.03.";
|
||||
}
|
||||
|
||||
if (stand === BeitragStand.BEZAHLT && bezahltAm) {
|
||||
const buchung = await prisma.buchung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
kontoId: giro.id,
|
||||
kategorieId: beitragsKategorie,
|
||||
datum: bezahltAm,
|
||||
art: BuchungsArt.EINNAHME,
|
||||
betrag,
|
||||
verwendungszweck: `Mitgliedsbeitrag ${jahr}`,
|
||||
personId: person.id,
|
||||
erfasstVonId: kassier?.id ?? null,
|
||||
},
|
||||
});
|
||||
buchungId = buchung.id;
|
||||
}
|
||||
|
||||
await prisma.beitrag.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId: person.id,
|
||||
jahr,
|
||||
satzId: satz.id,
|
||||
betrag,
|
||||
stand,
|
||||
buchungId,
|
||||
bezahltAm,
|
||||
mahnstufe,
|
||||
gemahntAm,
|
||||
erlassGrund,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Rechnungen -----------------------------------------------------------
|
||||
|
||||
const kostenersatz = kategorie("Kostenersatz");
|
||||
|
||||
const fehlalarmBuchung = await prisma.buchung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
kontoId: giro.id,
|
||||
kategorieId: kostenersatz,
|
||||
datum: datum(jahr, 4, 28),
|
||||
art: BuchungsArt.EINNAHME,
|
||||
betrag: "612.00",
|
||||
verwendungszweck: `Zahlungseingang Rechnung ${jahr}-0001`,
|
||||
lieferant: "Möbelhaus Sonnleitner GmbH",
|
||||
erfasstVonId: kassier?.id ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.rechnung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
nummer: `${jahr}-0001`,
|
||||
anlass: RechnungsAnlass.FEHLALARM,
|
||||
stand: RechnungStand.BEZAHLT,
|
||||
empfaenger: "Möbelhaus Sonnleitner GmbH",
|
||||
strasse: "Industriestraße 14",
|
||||
plz: "91315",
|
||||
ort: "Musterhausen",
|
||||
datum: datum(jahr, 3, 28),
|
||||
faelligAm: datum(jahr, 4, 25),
|
||||
betrag: "612.00",
|
||||
bemerkung:
|
||||
"Fehlalarm der Brandmeldeanlage am 26.03., verursacht durch Staub bei Umbauarbeiten. " +
|
||||
"Kostenersatz nach Art. 28 Abs. 2 Nr. 4 BayFwG.",
|
||||
buchungId: fehlalarmBuchung.id,
|
||||
bezahltAm: datum(jahr, 4, 28),
|
||||
erstelltVonId: kassier?.id ?? null,
|
||||
positionen: {
|
||||
create: [
|
||||
{
|
||||
organisationId,
|
||||
reihenfolge: 0,
|
||||
bezeichnung: "Einsatz LF 20, angefangene Stunde",
|
||||
menge: "1",
|
||||
einheit: "Std.",
|
||||
einzelpreis: "396.00",
|
||||
gesamtpreis: "396.00",
|
||||
},
|
||||
{
|
||||
organisationId,
|
||||
reihenfolge: 1,
|
||||
bezeichnung: "Einsatzkräfte, je angefangene Stunde",
|
||||
menge: "9",
|
||||
einheit: "Pers.",
|
||||
einzelpreis: "24.00",
|
||||
gesamtpreis: "216.00",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.rechnung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
nummer: `${jahr}-0002`,
|
||||
anlass: RechnungsAnlass.VERMIETUNG,
|
||||
stand: RechnungStand.VERSENDET,
|
||||
empfaenger: "Trachtenverein Almrausch Musterhausen e. V.",
|
||||
empfaengerZusatz: "z. Hd. Frau Vorstand Berger",
|
||||
strasse: "Dorfplatz 2",
|
||||
plz: "91315",
|
||||
ort: "Musterhausen",
|
||||
datum: datum(jahr, 6, 20),
|
||||
faelligAm: datum(jahr, 7, 18),
|
||||
betrag: "180.00",
|
||||
bemerkung: "Überlassung des Schulungsraums für die Jahresversammlung am 14.06.",
|
||||
erstelltVonId: kassier?.id ?? null,
|
||||
positionen: {
|
||||
create: [
|
||||
{
|
||||
organisationId,
|
||||
reihenfolge: 0,
|
||||
bezeichnung: "Nutzung Schulungsraum, Tagespauschale",
|
||||
menge: "1",
|
||||
einheit: "Tag",
|
||||
einzelpreis: "150.00",
|
||||
gesamtpreis: "150.00",
|
||||
},
|
||||
{
|
||||
organisationId,
|
||||
reihenfolge: 1,
|
||||
bezeichnung: "Reinigung",
|
||||
menge: "1",
|
||||
einheit: "pauschal",
|
||||
einzelpreis: "30.00",
|
||||
gesamtpreis: "30.00",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// --- Kassenprüfung des Vorjahres -----------------------------------------
|
||||
//
|
||||
// Zuletzt, und das ist der Punkt: ab hier ist der Zeitraum zu. Jede weitere
|
||||
// Buchung mit einem Datum aus dem Vorjahr wird von der Datenbank abgelehnt.
|
||||
|
||||
const pruefer = await prisma.person.findMany({
|
||||
where: { organisationId, aktiv: true, austrittsdatum: null },
|
||||
orderBy: { mitgliedsnummer: "desc" },
|
||||
take: 2,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Die Zahlen sind nicht erfunden, sondern die Summe der Buchungen oben:
|
||||
// Barkasse 250,00 + 3.860,00 − 1.420,60, Giro 4.800,00 + 7.280,00 − 7.113,60,
|
||||
// Rücklage unberührt. Ein Seed mit erfundenen Beständen wäre genau der
|
||||
// Fall, den die Kassenprüfung finden soll.
|
||||
const vorjahrsposten = [
|
||||
{ konto: bar.id, buch: "2689.40", gezaehlt: "2686.20" },
|
||||
{ konto: giro.id, buch: "4966.40", gezaehlt: "4966.40" },
|
||||
{ konto: ruecklage.id, buch: "12000.00", gezaehlt: "12000.00" },
|
||||
];
|
||||
|
||||
const abschluss = await prisma.kassenabschluss.create({
|
||||
data: {
|
||||
organisationId,
|
||||
vonAm: datum(vorjahr, 1, 1),
|
||||
bisAm: datum(vorjahr, 12, 31),
|
||||
bestandLautBuch: "19655.80",
|
||||
gezaehlterBestand: "19652.60",
|
||||
differenz: "-3.20",
|
||||
erlaeuterung:
|
||||
"In der Barkasse fehlen 3,20 €. Zurückzuführen auf das Maifest: das Wechselgeld " +
|
||||
"wurde am Abend gezählt, ein Beleg über den Nachkauf von Eis fehlt. Die Prüfer " +
|
||||
"halten den Betrag für erklärbar und schlagen Entlastung vor.",
|
||||
prueferId: pruefer[0]?.id ?? null,
|
||||
zweiterPrueferId: pruefer[1]?.id ?? null,
|
||||
geprueftAm: datum(vorjahr + 1, 2, 8),
|
||||
festgeschriebenAm: datum(vorjahr + 1, 2, 10),
|
||||
posten: {
|
||||
create: vorjahrsposten.map((posten) => ({
|
||||
organisationId,
|
||||
kontoId: posten.konto,
|
||||
bestandLautBuch: posten.buch,
|
||||
gezaehlterBestand: posten.gezaehlt,
|
||||
differenz: (Number(posten.gezaehlt) - Number(posten.buch)).toFixed(2),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
log(
|
||||
`Kasse: 3 Konten, ${STANDARDKATEGORIEN.length} Kategorien, ` +
|
||||
`${vorjahrszeilen.length + laufendeZeilen.length} Buchungen, ` +
|
||||
`${mitglieder.length} Beiträge, 2 Rechnungen und die Kassenprüfung ${vorjahr} ` +
|
||||
`(festgeschrieben — der Zeitraum ist zu, Abschluss ${abschluss.id.slice(0, 8)})`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ablauf
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -3293,6 +3943,9 @@ async function main(): Promise<void> {
|
||||
// Phase 8 — Lokale KI
|
||||
await wikiAnlegen(organisation.id);
|
||||
|
||||
// Phase 9 — Kassenverwaltung
|
||||
await kassendatenAnlegen(organisation.id);
|
||||
|
||||
log("Fertig.");
|
||||
log("");
|
||||
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
|
||||
|
||||
Reference in New Issue
Block a user