427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
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();
|
||
}
|
||
});
|
||
});
|