import { PrismaClient } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; /** * Die Absicherungen der Kasse — geprüft dort, wo sie durchgesetzt werden: in * der Datenbank. * * Eine Feuerwehrkasse wird von einem Ehrenamtlichen geführt und von zwei * anderen geprüft. Was die Prüfung wert ist, hängt einzig daran, dass sich * das Geprüfte hinterher nicht mehr ändern lässt. Ein Zustandscheck im Router * reicht dafür nicht: ein vergessener Zweig, ein Skript, ein Import — und der * geprüfte Bestand stimmt nicht mehr, ohne dass irgendwo etwas auffällt. * * Deshalb sitzen die Regeln in Triggern und CHECKs (Migration 20260904140000), * und deshalb prüft dieser Test sie über den Systemzugang: wenn nicht einmal * der Eigentümer der Datenbank daran vorbeikommt, kommt niemand vorbei. * * Der Test braucht eine laufende Datenbank. Ist keine erreichbar, überspringt * er sich selbst, damit "npm run test" auch ohne Docker durchläuft. */ const APP_URL = process.env.DATABASE_URL ?? ""; const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL; const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } }); const SLUG = "test-kassen-wehr"; const FREMD_SLUG = "test-kassen-fremdwehr"; let erreichbar = false; let organisationId = ""; let fremdOrganisationId = ""; let kontoId = ""; let kategorieId = ""; let prueferId = ""; /** Ein Datum weit in der Vergangenheit, damit nichts mit dem Seed kollidiert. */ function tag(jahr: number, monat: number, tagImMonat: number): Date { return new Date(Date.UTC(jahr, monat - 1, tagImMonat)); } beforeAll(async () => { try { await system.$queryRaw`SELECT 1`; erreichbar = true; } catch { return; } const organisation = await system.organisation.upsert({ where: { slug: SLUG }, update: {}, create: { slug: SLUG, name: "Kassen-Testwehr", kurzname: "KTW" }, }); organisationId = organisation.id; const fremd = await system.organisation.upsert({ where: { slug: FREMD_SLUG }, update: {}, create: { slug: FREMD_SLUG, name: "Kassen-Fremdwehr", kurzname: "KFW" }, }); fremdOrganisationId = fremd.id; const konto = await system.kassenkonto.upsert({ where: { organisationId_bezeichnung: { organisationId, bezeichnung: "Testkasse" } }, update: {}, create: { organisationId, bezeichnung: "Testkasse", art: "BAR", anfangsbestand: "100.00" }, }); kontoId = konto.id; const kategorie = await system.buchungskategorie.upsert({ where: { organisationId_name: { organisationId, name: "Testkategorie" } }, update: {}, create: { organisationId, name: "Testkategorie", art: "AUSGABE" }, }); kategorieId = kategorie.id; // Ohne Prüfer lässt sich kein Abschluss festschreiben — dafür sorgt // ein eigener CHECK, den ein Test weiter unten prüft. const person = await system.person.upsert({ where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "KTW-P" } }, update: {}, create: { organisationId, vorname: "Prüfer", nachname: "Testwehr", mitgliedsnummer: "KTW-P", eintrittsdatum: tag(2015, 1, 1), }, }); prueferId = person.id; }); afterAll(async () => { if (erreichbar) { // Der Abschluss ist für die Anwendung unlöschbar; hier räumt der // Eigentümer auf, dem das REVOKE nicht gilt. await system.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; await tx.organisation.deleteMany({ where: { slug: { in: [SLUG, FREMD_SLUG] } } }); }); } await system.$disconnect(); }); /** Eine Buchung anlegen — wahlweise gleich festgeschrieben. */ async function buchung(datum: Date, betrag: string, festgeschrieben = false) { return system.buchung.create({ data: { organisationId, kontoId, kategorieId, datum, art: "AUSGABE", betrag, verwendungszweck: "Prüfling", festgeschriebenAm: festgeschrieben ? new Date() : null, }, }); } describe.runIf(process.env.DATABASE_URL)("Festgeschriebene Buchungen", () => { it("lässt Entwürfe ändern und verwerfen", async ({ skip }) => { if (!erreichbar) return skip(); const entwurf = await buchung(tag(2019, 3, 1), "12.00"); const geaendert = await system.buchung.update({ where: { id: entwurf.id }, data: { betrag: "15.00", verwendungszweck: "korrigiert" }, }); expect(geaendert.betrag.toFixed(2)).toBe("15.00"); await system.buchung.delete({ where: { id: entwurf.id } }); }); it("weist jede inhaltliche Änderung an einer festgeschriebenen Buchung ab", async ({ skip }) => { if (!erreichbar) return skip(); const fest = await buchung(tag(2019, 3, 2), "42.00", true); await expect( system.buchung.update({ where: { id: fest.id }, data: { betrag: "43.00" } }), ).rejects.toThrow(/festgeschrieben/i); await expect( system.buchung.update({ where: { id: fest.id }, data: { verwendungszweck: "anders" } }), ).rejects.toThrow(/festgeschrieben/i); await expect( system.buchung.update({ where: { id: fest.id }, data: { datum: tag(2019, 4, 1) } }), ).rejects.toThrow(/festgeschrieben/i); }); it("lässt sie auch nicht löschen", async ({ skip }) => { if (!erreichbar) return skip(); const fest = await buchung(tag(2019, 3, 3), "42.00", true); await expect(system.buchung.delete({ where: { id: fest.id } })).rejects.toThrow( /festgeschrieben/i, ); }); it("lässt den Verweis auf die Gegenbuchung eintragen", async ({ skip }) => { if (!erreichbar) return skip(); // Die eine erlaubte Ausnahme: eine Stornierung entsteht später. Sie // einzutragen ist keine Änderung des Vorgangs, sondern seine // Fortschreibung — und ohne diesen Weg gäbe es keine Korrekturmöglichkeit. const original = await buchung(tag(2019, 3, 4), "42.00", true); const gegen = await buchung(tag(2019, 3, 5), "42.00", true); const verknuepft = await system.buchung.update({ where: { id: original.id }, data: { storniertVonId: gegen.id }, }); expect(verknuepft.storniertVonId).toBe(gegen.id); }); it("lässt sich das Festschreiben nicht wieder zurücknehmen", async ({ skip }) => { if (!erreichbar) return skip(); const fest = await buchung(tag(2019, 3, 6), "42.00", true); await expect( system.buchung.update({ where: { id: fest.id }, data: { festgeschriebenAm: null } }), ).rejects.toThrow(/festgeschrieben/i); }); }); describe.runIf(process.env.DATABASE_URL)("Geprüfter Zeitraum", () => { it("nimmt keine neue Buchung mehr an und lässt keine hineindatieren", async ({ skip }) => { if (!erreichbar) return skip(); // Erst die Buchungen, dann der Abschluss — die Reihenfolge, die auch der // Kassier einhalten muss. const spaeter = await buchung(tag(2020, 6, 1), "10.00"); await system.kassenabschluss.create({ data: { organisationId, vonAm: tag(2019, 1, 1), bisAm: tag(2019, 12, 31), bestandLautBuch: "100.00", gezaehlterBestand: "100.00", differenz: "0.00", prueferId, geprueftAm: tag(2020, 2, 1), festgeschriebenAm: new Date(), }, }); // Neu in den geprüften Zeitraum: abgelehnt. await expect(buchung(tag(2019, 7, 1), "5.00")).rejects.toThrow(/geprueft|abgeschlossen/i); // Eine vorhandene hineinschieben: ebenso. Ohne diese zweite Hälfte // stimmte der geprüfte Bestand nach einem Datumswechsel nicht mehr. await expect( system.buchung.update({ where: { id: spaeter.id }, data: { datum: tag(2019, 7, 1) } }), ).rejects.toThrow(/geprueft|abgeschlossen/i); // Außerhalb des Zeitraums bleibt alles offen. const danach = await buchung(tag(2020, 6, 2), "5.00"); expect(danach.id).toBeTruthy(); }); it("hält den festgeschriebenen Abschluss selbst fest — bis auf den Bericht", async ({ skip }) => { if (!erreichbar) return skip(); const abschluss = await system.kassenabschluss.create({ data: { organisationId, vonAm: tag(2021, 1, 1), bisAm: tag(2021, 12, 31), bestandLautBuch: "100.00", gezaehlterBestand: "97.00", differenz: "-3.00", erlaeuterung: "Wechselgeld vom Fest, Beleg fehlt.", prueferId, geprueftAm: tag(2022, 2, 1), festgeschriebenAm: new Date(), }, }); await expect( system.kassenabschluss.update({ where: { id: abschluss.id }, data: { gezaehlterBestand: "100.00", differenz: "0.00" }, }), ).rejects.toThrow(/festgeschrieben/i); await expect( system.kassenabschluss.update({ where: { id: abschluss.id }, data: { erlaeuterung: "doch alles in Ordnung" }, }), ).rejects.toThrow(/festgeschrieben/i); // Der Prüfbericht entsteht im Hintergrund, also nach dem Festschreiben. // Ihn nachzutragen muss offen bleiben — sonst gäbe es nie einen. const datei = await system.datei.create({ data: { organisationId, bucket: "test", objektSchluessel: `test/${abschluss.id}.pdf`, dateiname: "pruefbericht.pdf", mimeTyp: "application/pdf", groesseBytes: BigInt(2048), }, }); const mitBericht = await system.kassenabschluss.update({ where: { id: abschluss.id }, data: { berichtDateiId: datei.id }, }); expect(mitBericht.berichtDateiId).toBe(datei.id); }); }); describe.runIf(process.env.DATABASE_URL)("Was die Datenbank gar nicht erst annimmt", () => { it("keine Buchung mit einem Betrag von null oder darunter", async ({ skip }) => { if (!erreichbar) return skip(); // Der Betrag ist immer positiv, die Richtung steht in `art`. Eine // negative Ausgabe wäre eine Einnahme in Verkleidung — und in keiner // Auswertung als solche zu erkennen. await expect(buchung(tag(2023, 1, 1), "0.00")).rejects.toThrow(/betrag_positiv/i); await expect(buchung(tag(2023, 1, 2), "-5.00")).rejects.toThrow(/betrag_positiv/i); }); it("keine Buchung, die zugleich an eine Person und an einen Dritten geht", async ({ skip }) => { if (!erreichbar) return skip(); const person = await system.person.create({ data: { organisationId, vorname: "Test", nachname: "Person", mitgliedsnummer: "KTW-1", eintrittsdatum: tag(2015, 1, 1), }, }); await expect( system.buchung.create({ data: { organisationId, kontoId, kategorieId, datum: tag(2023, 2, 1), art: "AUSGABE", betrag: "10.00", verwendungszweck: "beides", personId: person.id, lieferant: "Werkstatt Müller", }, }), ).rejects.toThrow(/gegenueber_eindeutig/i); }); it("kein erlassener Beitrag ohne Begründung", async ({ skip }) => { if (!erreichbar) return skip(); const person = await system.person.create({ data: { organisationId, vorname: "Erlass", nachname: "Person", mitgliedsnummer: "KTW-2", eintrittsdatum: tag(2015, 1, 1), }, }); // Ein erlassener Beitrag ohne Begründung ist in der Kassenprüfung eine // offene Frage — und fünf Jahre später weiß es niemand mehr. await expect( system.beitrag.create({ data: { organisationId, personId: person.id, jahr: 2023, betrag: "25.00", stand: "ERLASSEN" }, }), ).rejects.toThrow(/erlass_begruendet/i); const mitGrund = await system.beitrag.create({ data: { organisationId, personId: person.id, jahr: 2023, betrag: "25.00", stand: "ERLASSEN", erlassGrund: "Längere Erkrankung", }, }); expect(mitGrund.id).toBeTruthy(); }); it("keinen Abschluss, dessen Differenz nicht zu seinen Zahlen passt", async ({ skip }) => { if (!erreichbar) return skip(); await expect( system.kassenabschluss.create({ data: { organisationId, vonAm: tag(2024, 1, 1), bisAm: tag(2024, 12, 31), bestandLautBuch: "100.00", gezaehlterBestand: "90.00", // Gerechnet wären das −10,00. Eine geschönte Differenz wäre genau // das, was eine Kassenprüfung finden soll. differenz: "0.00", }, }), ).rejects.toThrow(/differenz_stimmt/i); }); it("keine Rechnung, die fällig wird, bevor sie geschrieben wurde", async ({ skip }) => { if (!erreichbar) return skip(); await expect( system.rechnung.create({ data: { organisationId, nummer: "TEST-0001", empfaenger: "Irgendwer", datum: tag(2024, 5, 1), faelligAm: tag(2024, 4, 1), betrag: "100.00", }, }), ).rejects.toThrow(/faellig_nach_datum/i); }); }); describe.runIf(process.env.DATABASE_URL)("Mandantentrennung", () => { it("trennt die Kassen zweier Wehren über Row Level Security", async ({ skip }) => { if (!erreichbar) return skip(); if (!APP_URL || APP_URL === SYSTEM_URL) return skip(); // Der Anwendungszugang unterliegt RLS. Setzt er den Kontext der fremden // Wehr, darf er von der Testkasse nichts sehen — nicht einmal, dass es // sie gibt. const app = new PrismaClient({ datasources: { db: { url: APP_URL } } }); try { const sichtbar = await app.$transaction(async (tx) => { await tx.$executeRawUnsafe( `SELECT set_config('app.current_organisation', '${fremdOrganisationId}', true)`, ); return tx.buchung.count({ where: { organisationId } }); }); expect(sichtbar).toBe(0); const eigene = await app.$transaction(async (tx) => { await tx.$executeRawUnsafe( `SELECT set_config('app.current_organisation', '${organisationId}', true)`, ); return tx.buchung.count(); }); expect(eigene).toBeGreaterThan(0); } finally { await app.$disconnect(); } }); });