Phase 9: Kassenverwaltung (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-09-05 02:30:51 +00:00
parent d70acad4cd
commit 2b53556175
78 changed files with 12314 additions and 42 deletions
+426
View File
@@ -0,0 +1,426 @@
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();
}
});
});