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:
Florian Wilde
2026-09-04 19:23:34 +02:00
co-authored by Claude Opus 5
parent d70acad4cd
commit 4165334c5a
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();
}
});
});