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