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
+8 -10
View File
@@ -67,9 +67,7 @@ test.describe("Anmeldung", () => {
await expect(page.getByRole("note")).toContainText("analoge Überwachung");
});
test("zeigt die Navigation und markiert künftige Module als noch nicht verfügbar", async ({
page,
}) => {
test("zeigt die Navigation mit allen umgesetzten Modulen", async ({ page }) => {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
@@ -95,13 +93,13 @@ test.describe("Anmeldung", () => {
navigation.getByRole("link", { name: "Benachrichtigungen" }),
).toBeVisible();
// Die Kasse kommt erst in Phase 9 — der Eintrag ist sichtbar, aber kein
// Verweis. Beim Abschluss einer Phase wandert diese Zusicherung eine Zeile
// weiter nach unten.
await expect(navigation.getByText("Kasse")).toBeVisible();
await expect(navigation.getByRole("link", { name: "Kasse" })).toHaveCount(
0,
);
// Die Kasse ist seit Phase 9 ein Verweis.
//
// Bis dahin stand hier die Gegenprobe: der Eintrag sichtbar, aber
// ausgegraut, weil das Modul noch fehlte. Mit Phase 9 gibt es kein
// künftiges Modul mehr — eine Zusicherung über ein Modul, das es nicht
// gibt, wäre keine. Kommt später eines dazu, gehört sie hierher zurück.
await expect(navigation.getByRole("link", { name: "Kasse" })).toBeVisible();
});
});
+258
View File
@@ -0,0 +1,258 @@
import { expect, test } from "@playwright/test";
import { anmelden, eigenerName } from "./wehr";
/**
* E2E-Tests für Phase 9 — die Kassenverwaltung.
*
* Was hier geprüft wird, ist nicht die Kür, sondern das, wovon die
* Kassenprüfung abhängt:
*
* * die Übersicht zeigt Bestände, die aus den Buchungen gerechnet sind
* * eine Buchung entsteht und erscheint im Kassenbuch
* * eine festgeschriebene Buchung lässt sich nicht mehr ändern — die
* Oberfläche bietet Gegenbuchen an, nicht Bearbeiten
* * die Beitragsliste zeigt Rückstände, nicht nur Summen
* * eine Rechnung entsteht als Entwurf mit fortlaufender Nummer
* * der geprüfte Zeitraum des Vorjahres ist zu
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet
* (`npm run db:test-reset`).
*/
test.describe.configure({ mode: "serial" });
const JAHR = new Date().getFullYear();
test.describe("Kassenübersicht", () => {
test("zeigt die Konten mit gerechnetem Bestand", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse");
await expect(page.getByRole("heading", { name: "Kasse", level: 1 })).toBeVisible();
// Aus dem Seed — die Überschriften der Kontenkarten, nicht irgendein
// Vorkommen des Wortes: "Barkasse" steht auch in Verwendungszwecken.
await expect(page.getByRole("heading", { name: "Barkasse" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Girokonto Raiffeisenbank" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Rücklage Fahrzeugbeschaffung" }),
).toBeVisible();
// Ein Bestand steht da, und zwar als Betrag — nicht als leeres Feld.
await expect(page.getByText(/\d+,\d{2}\s€/).first()).toBeVisible();
});
test("führt zu den vier Aufgaben der Kasse", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse");
for (const [name, pfad] of [
["Buchungen", "/kasse/buchungen"],
["Beiträge", "/kasse/beitraege"],
["Rechnungen", "/kasse/rechnungen"],
["Abschluss", "/kasse/abschluss"],
] as const) {
await expect(page.locator(`a[href="${pfad}"]`).first()).toBeVisible();
expect(name.length).toBeGreaterThan(0);
}
});
});
test.describe("Kassenbuch", () => {
test("zeigt die Buchungen mit Summen über alle Treffer", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/buchungen");
await expect(page.getByRole("heading", { name: "Buchungen", level: 1 })).toBeVisible();
// Die drei Summen oben. Sie beziehen sich auf alle Treffer, nicht nur auf
// die angezeigte Seite — sonst bedeutete die Zahl nichts.
await expect(page.getByText("Einnahmen", { exact: true })).toBeVisible();
await expect(page.getByText("Ausgaben", { exact: true })).toBeVisible();
await expect(page.getByText("Saldo", { exact: true })).toBeVisible();
// Aus dem Seed.
await expect(page.getByRole("cell", { name: /Maifest/ }).first()).toBeVisible();
});
test("erfasst eine Buchung und zeigt sie danach im Kassenbuch", async ({ page }) => {
const zweck = eigenerName("Buchung");
await anmelden(page);
await page.goto("/kasse/buchungen");
await page.getByRole("button", { name: "Buchung erfassen" }).click();
await page.getByLabel("Betrag", { exact: true }).fill("42.50");
await page.getByLabel("Verwendungszweck").fill(zweck);
// Kategorie wählen — die erste Ausgabenkategorie reicht.
await page.getByLabel("Kategorie", { exact: true }).click();
await page.getByRole("option").first().click();
await page.getByRole("button", { name: "Buchen" }).click();
// In der Zeile mit dem eigenen Verwendungszweck — der Betrag allein wäre
// nicht eindeutig, sobald ein zweiter Lauf dieselbe Buchung erzeugt hat.
const zeile = page.getByRole("row", { name: new RegExp(zweck) });
await expect(zeile).toBeVisible();
await expect(zeile.getByRole("cell", { name: "−42,50 €" })).toBeVisible();
});
test("bietet für festgeschriebene Buchungen Gegenbuchen statt Bearbeiten an", async ({
page,
}) => {
await anmelden(page);
await page.goto("/kasse/buchungen");
// Die Vorjahresbuchungen sind festgeschrieben — daran hängt die ganze
// Kassenprüfung. Die Oberfläche muss das zeigen, nicht nur der Server
// beim Versuch. Die Pressluftatmer gibt es im Seed nur im Vorjahr.
const fest = page.getByRole("row", { name: /Pressluftatmer/ });
await expect(fest).toBeVisible();
await expect(fest.getByRole("button", { name: "Gegenbuchen" })).toBeVisible();
await expect(fest.getByRole("button", { name: "Verwerfen" })).toHaveCount(0);
// Und die Gegenprobe: eine Buchung des laufenden Jahres ist noch ein
// Entwurf und darf verworfen werden.
const offen = page.getByRole("row", { name: /Kupplung MTW/ });
await expect(offen.getByRole("button", { name: "Verwerfen" })).toBeVisible();
await expect(offen.getByRole("button", { name: "Gegenbuchen" })).toHaveCount(0);
});
test("erklärt beim Gegenbuchen, warum beide Buchungen stehen bleiben", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/buchungen");
await page
.getByRole("row", { name: /Pressluftatmer/ })
.getByRole("button", { name: "Gegenbuchen" })
.click();
await expect(page.getByRole("dialog")).toContainText("Kassenprüfer");
// Ohne Grund geht es nicht weiter — er steht später im Verwendungszweck.
await expect(page.getByRole("button", { name: "Gegenbuchen" }).last()).toBeDisabled();
});
});
test.describe("Beiträge", () => {
test("zeigt Sollstellung, Offenes und die Rückstände", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/beitraege");
await expect(page.getByRole("heading", { name: "Mitgliedsbeiträge", level: 1 })).toBeVisible();
await expect(page.getByText("Sollstellung")).toBeVisible();
await expect(page.getByText("Bezahlt", { exact: true }).first()).toBeVisible();
// Die Sätze aus dem Seed.
await expect(page.getByText("Aktive und Passive")).toBeVisible();
await expect(page.getByText("Jugendfeuerwehr").first()).toBeVisible();
});
test("zeigt die Vorschau des Beitragslaufs, bevor etwas entsteht", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/beitraege");
await page.getByRole("button", { name: `Beitragslauf ${JAHR}` }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Das Jahr ist im Seed bereits gelaufen — der Lauf darf deshalb nichts
// doppelt anlegen und muss genau das sagen.
await expect(dialog).toContainText(/bereits einen|0 Beiträge/);
});
});
test.describe("Rechnungen", () => {
test("zeigt die Rechnungen aus dem Bestand mit ihrem Stand", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/rechnungen");
await expect(page.getByRole("heading", { name: "Rechnungen", level: 1 })).toBeVisible();
await expect(page.getByRole("cell", { name: `${JAHR}-0001` })).toBeVisible();
await expect(page.getByRole("cell", { name: /Sonnleitner/ })).toBeVisible();
await expect(page.getByRole("cell", { name: /Trachtenverein/ })).toBeVisible();
});
test("schreibt eine Rechnung mit fortlaufender Nummer", async ({ page }) => {
const empfaenger = eigenerName("Rechnung");
await anmelden(page);
await page.goto("/kasse/rechnungen");
await page.getByRole("button", { name: "Rechnung schreiben" }).click();
await page.getByLabel("Empfänger").fill(empfaenger);
await page.getByLabel("Bezeichnung").fill("Einsatz LF 20, angefangene Stunde");
await page.getByLabel("Einzelpreis").fill("396.00");
// Der Betrag wird gerechnet, nicht eingegeben — hier zur Anzeige, auf dem
// Server noch einmal verbindlich.
await expect(page.getByRole("dialog")).toContainText("396,00 €");
await page.getByRole("button", { name: "Als Entwurf anlegen" }).click();
await expect(page.getByText(/Rechnung \d{4}-\d{4} als Entwurf angelegt/)).toBeVisible();
await expect(page.getByRole("cell", { name: new RegExp(empfaenger) })).toBeVisible();
});
});
test.describe("Abschluss und Auswertung", () => {
test("zeigt die Kassenprüfung des Vorjahres als festgeschrieben", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/abschluss");
await expect(
page.getByRole("heading", { name: "Abschluss und Auswertung", level: 1 }),
).toBeVisible();
await expect(page.getByText("Festgeschrieben").first()).toBeVisible();
// Die Differenz aus dem Seed steht mit ihrer Erklärung da. Genau das ist
// der Zweck des Feldes: in fünf Jahren weiß sonst niemand mehr, woher sie
// kam.
await expect(page.getByText("−3,20 €")).toBeVisible();
await expect(page.getByText(/Wechselgeld/)).toBeVisible();
});
test("weist einen Abschluss ab, dessen Zeitraum schon geprüft ist", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/abschluss");
await page.getByRole("button", { name: "Kassenprüfung anlegen" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Der Dialog schlägt von sich aus das Vorjahr vor — und genau das ist
// bereits geprüft. Erst die Kontenliste abwarten: sie kommt vom Server,
// und ohne sie wären null Felder zu füllen.
await expect(dialog.getByRole("columnheader", { name: "Laut Buch" })).toBeVisible();
const felder = dialog.getByRole("spinbutton");
const anzahl = await felder.count();
for (let stelle = 0; stelle < anzahl; stelle += 1) {
await felder.nth(stelle).fill("100");
}
await dialog.getByRole("button", { name: "Als Entwurf anlegen" }).click();
await expect(page.getByText(/überschneidet sich mit einem bereits geprüften/)).toBeVisible();
});
test("zeigt die Jahresauswertung mit Vorjahresvergleich", async ({ page }) => {
await anmelden(page);
await page.goto("/kasse/abschluss");
await page.getByRole("button", { name: "Auswertung" }).click();
await expect(page.getByText(`Verlauf ${JAHR}`)).toBeVisible();
await expect(page.getByText("Einnahmen nach Kategorie")).toBeVisible();
await expect(page.getByText("Ausgaben nach Kategorie")).toBeVisible();
// Eine Zahl ohne Vergleich sagt nichts — das Vorjahr steht daneben.
await expect(page.getByText(new RegExp(`${JAHR - 1}:`)).first()).toBeVisible();
});
});
+218
View File
@@ -0,0 +1,218 @@
import { describe, expect, it } from "vitest";
import { auszugLesen, camtLesen, mt940Lesen } from "@/lib/kasse/bankimport";
/**
* Kontoauszüge einlesen.
*
* Die Beispiele sind bewusst so unaufgeräumt wie echte Dateien: mit
* Namensraum-Präfix, mit dem Verwendungszweck über mehrere Felder verteilt,
* mit Fortsetzungszeilen. Ein Parser, der nur an der eigenen sauberen
* Beispieldatei geprüft wurde, geht beim ersten echten Auszug kaputt.
*
* Wichtig ist außerdem, was der Parser NICHT tut: er bucht nichts. Er liefert
* Vorschläge, und alles, was er nicht lesen konnte, steht als Fehler daneben —
* statt still zu verschwinden.
*/
const CAMT = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct><Id><IBAN>DE02701500000000594937</IBAN></Id></Acct>
<Ntry>
<Amt Ccy="EUR">412.90</Amt>
<CdtDbtInd>DBIT</CdtDbtInd>
<BookgDt><Dt>2026-03-12</Dt></BookgDt>
<ValDt><Dt>2026-03-13</Dt></ValDt>
<AcctSvcrRef>2026031200123456</AcctSvcrRef>
<NtryDtls><TxDtls>
<RltdPties>
<Cdtr><Nm>Tankstelle Musterhausen</Nm></Cdtr>
<CdtrAcct><Id><IBAN>DE12500105170648489890</IBAN></Id></CdtrAcct>
</RltdPties>
<RmtInf>
<Ustrd>Sammelrechnung Februar</Ustrd>
<Ustrd>Diesel LF 20</Ustrd>
</RmtInf>
</TxDtls></NtryDtls>
</Ntry>
<Ntry>
<Amt Ccy="EUR">6500.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<BookgDt><Dt>2026-01-20</Dt></BookgDt>
<NtryDtls><TxDtls>
<RltdPties><Dbtr><Nm>Gemeinde Musterhausen</Nm></Dbtr></RltdPties>
<RmtInf><Ustrd>Zuschuss Feuerwehr 2026</Ustrd></RmtInf>
</TxDtls></NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`;
const MT940 = [
":20:STARTUMS",
":25:DE02701500000000594937",
":28C:00012/001",
":60F:C260301EUR4800,00",
":61:2603120312DR412,90NMSCNONREF",
":86:117?00UEBERWEISUNG?20Sammelrechnung Februar?21Diesel LF 20",
"?32Tankstelle Musterhausen",
":61:2601200120CR6500,00NTRFNONREF",
":86:166?00GUTSCHRIFT?20Zuschuss Feuerwehr 2026?32Gemeinde Musterhausen",
":62F:C260331EUR10887,10",
].join("\r\n");
describe("CAMT.053", () => {
it("liest Konto, Richtung, Betrag und Datum", () => {
const auszug = camtLesen(CAMT);
expect(auszug.format).toBe("CAMT.053");
expect(auszug.konto).toBe("DE02701500000000594937");
expect(auszug.fehler).toHaveLength(0);
expect(auszug.posten).toHaveLength(2);
const [ausgabe, einnahme] = auszug.posten;
expect(ausgabe?.art).toBe("AUSGABE");
expect(ausgabe?.betrag.toFixed(2)).toBe("412.90");
expect(ausgabe?.datum.toISOString().slice(0, 10)).toBe("2026-03-12");
expect(einnahme?.art).toBe("EINNAHME");
expect(einnahme?.betrag.toFixed(2)).toBe("6500.00");
});
it("setzt einen über mehrere Ustrd verteilten Verwendungszweck zusammen", () => {
const auszug = camtLesen(CAMT);
expect(auszug.posten[0]?.verwendungszweck).toBe("Sammelrechnung Februar Diesel LF 20");
});
it("holt die Gegenseite je nach Richtung aus Creditor oder Debtor", () => {
const auszug = camtLesen(CAMT);
expect(auszug.posten[0]?.gegenseite).toBe("Tankstelle Musterhausen");
expect(auszug.posten[0]?.gegenIban).toBe("DE12500105170648489890");
expect(auszug.posten[1]?.gegenseite).toBe("Gemeinde Musterhausen");
});
it("übernimmt die Kennung der Bank — daran hängt die Doppelerkennung", () => {
const auszug = camtLesen(CAMT);
expect(auszug.posten[0]?.kennung).toBe("2026031200123456");
});
it("liefert bei zweimaligem Einlesen dieselben Kennungen", () => {
// Der eigentliche Zweck: derselbe Auszug zweimal eingelesen darf nicht
// zwei Monatssätze in der Kasse ergeben.
const erste = camtLesen(CAMT).posten.map((posten) => posten.kennung);
const zweite = camtLesen(CAMT).posten.map((posten) => posten.kennung);
expect(erste).toEqual(zweite);
});
it("kommt mit Namensraum-Präfixen zurecht", () => {
const mitPraefix = CAMT.replace(/<(\/?)(Ntry|Amt|CdtDbtInd|Ustrd)\b/g, "<$1ns2:$2");
const auszug = camtLesen(mitPraefix);
expect(auszug.posten).toHaveLength(2);
});
it("meldet einen unbrauchbaren Umsatz, statt ihn stillschweigend zu übergehen", () => {
const kaputt = CAMT.replace("<CdtDbtInd>DBIT</CdtDbtInd>", "");
const auszug = camtLesen(kaputt);
expect(auszug.posten).toHaveLength(1);
expect(auszug.fehler).toHaveLength(1);
expect(auszug.fehler[0]?.stelle).toBe("Umsatz 1");
});
it("sagt es deutlich, wenn gar kein Umsatz drinsteht", () => {
const auszug = camtLesen("<Document></Document>");
expect(auszug.posten).toHaveLength(0);
expect(auszug.fehler[0]?.grund).toMatch(/Ntry/);
});
});
describe("MT940", () => {
it("liest Konto, Richtung, Betrag und Buchungsdatum", () => {
const auszug = mt940Lesen(MT940);
expect(auszug.format).toBe("MT940");
expect(auszug.konto).toBe("DE02701500000000594937");
expect(auszug.fehler).toHaveLength(0);
expect(auszug.posten).toHaveLength(2);
const [ausgabe, einnahme] = auszug.posten;
expect(ausgabe?.art).toBe("AUSGABE");
expect(ausgabe?.betrag.toFixed(2)).toBe("412.90");
// Buchungsdatum 0312 vor Valuta 260312 — beides März 2026.
expect(ausgabe?.datum.toISOString().slice(0, 10)).toBe("2026-03-12");
expect(einnahme?.art).toBe("EINNAHME");
expect(einnahme?.betrag.toFixed(2)).toBe("6500.00");
});
it("setzt den Verwendungszweck aus den Unterfeldern ?20 ff. zusammen", () => {
const auszug = mt940Lesen(MT940);
expect(auszug.posten[0]?.verwendungszweck).toBe("Sammelrechnung Februar Diesel LF 20");
});
it("hängt Fortsetzungszeilen an, statt sie zu verlieren", () => {
// ?32 steht im Beispiel auf einer eigenen Zeile ohne Kennsatz.
const auszug = mt940Lesen(MT940);
expect(auszug.posten[0]?.gegenseite).toBe("Tankstelle Musterhausen");
});
it("liest den Jahreswechsel im Auszug richtig", () => {
// Valuta 260105, Buchung 1229: das Buchungsdatum stammt aus dem Vorjahr.
const auszug = mt940Lesen(
[":25:DE02701500000000594937", ":61:2601051229DR36,00NMSCNONREF", ":86:117?20Kontofuehrung"].join(
"\n",
),
);
expect(auszug.posten[0]?.datum.toISOString().slice(0, 10)).toBe("2025-12-29");
});
it("kehrt bei Rückbuchungen die Richtung um", () => {
const auszug = mt940Lesen(
[":25:DE02701500000000594937", ":61:2603120312RD120,00NMSCNONREF", ":86:117?20Ruecklastschrift"].join(
"\n",
),
);
// RD ist die Rückgabe einer Lastschrift — das Geld kommt zurück.
expect(auszug.posten[0]?.art).toBe("EINNAHME");
});
it("meldet eine unlesbare Umsatzzeile mit ihrer Nummer", () => {
const auszug = mt940Lesen(
[":25:DE02701500000000594937", ":61:Unsinn", ":86:117?20egal"].join("\n"),
);
expect(auszug.posten).toHaveLength(0);
expect(auszug.fehler[0]?.stelle).toBe("Umsatz 1");
});
it("kommt ohne :86: aus und vergibt trotzdem eine Kennung", () => {
const auszug = mt940Lesen(
[":25:DE02701500000000594937", ":61:2603120312DR412,90NMSCNONREF"].join("\n"),
);
expect(auszug.posten).toHaveLength(1);
expect(auszug.posten[0]?.verwendungszweck).toBe("(kein Verwendungszweck)");
expect(auszug.posten[0]?.kennung).not.toBe("");
});
});
describe("Formaterkennung", () => {
it("erkennt CAMT am Inhalt, nicht an der Endung", () => {
expect(auszugLesen(CAMT).format).toBe("CAMT.053");
});
it("erkennt MT940 an den Kennsätzen", () => {
expect(auszugLesen(MT940).format).toBe("MT940");
});
it("sagt bei einem unbekannten Format, was erwartet wird", () => {
const auszug = auszugLesen("Datum;Betrag;Zweck\n12.03.2026;-412,90;Diesel");
expect(auszug.posten).toHaveLength(0);
expect(auszug.fehler[0]?.grund).toMatch(/CAMT\.053|MT940/);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { Prisma } from "@prisma/client";
import { describe, expect, it } from "vitest";
import {
alsText,
alsWirkung,
bestand,
betrag,
betragLesen,
bilanz,
positionssumme,
runden,
summe,
} from "@/lib/kasse/betrag";
/**
* Das Rechnen mit Geld.
*
* Der Grund für diese Datei ist eine einzige Zeile, die es hier nicht geben
* darf: `0.1 + 0.2`. In Fließkomma ergibt das 0.30000000000000004, und in
* einer Kasse ist das kein Rundungsfehler, sondern eine Differenz in der
* Kassenprüfung, die niemand erklären kann. Deshalb rechnet die gesamte
* Kasse mit `Prisma.Decimal`, und deshalb prüft dieser Test genau das nach.
*/
describe("Beträge lesen", () => {
it("nimmt Zahl, Text und Decimal entgegen", () => {
expect(betrag(12.5).toFixed(2)).toBe("12.50");
expect(betrag("12.50").toFixed(2)).toBe("12.50");
expect(betrag(new Prisma.Decimal("12.5")).toFixed(2)).toBe("12.50");
});
it("macht aus fehlenden Angaben null Euro", () => {
expect(betrag(null).toFixed(2)).toBe("0.00");
expect(betrag(undefined).toFixed(2)).toBe("0.00");
expect(betrag("").toFixed(2)).toBe("0.00");
});
});
describe("Summe", () => {
it("rechnet ohne Fließkommafehler", () => {
// Die Probe aufs Exempel: in `number` ergäbe das 0.30000000000000004.
expect(summe(["0.10", "0.20"]).toFixed(2)).toBe("0.30");
});
it("addiert eine ganze Kassenseite auf den Cent", () => {
const werte = ["1284.50", "412.90", "2340.00", "868.00", "1150.00", "624.00", "398.20", "36.00"];
expect(summe(werte).toFixed(2)).toBe("7113.60");
});
it("ergibt bei leerer Liste null Euro und nicht undefined", () => {
expect(summe([]).toFixed(2)).toBe("0.00");
});
});
describe("Runden", () => {
it("rundet kaufmännisch — ab der halben Stelle nach oben", () => {
expect(runden(new Prisma.Decimal("2.345")).toFixed(2)).toBe("2.35");
expect(runden(new Prisma.Decimal("2.344")).toFixed(2)).toBe("2.34");
// Und nicht bankers rounding: 2,355 wird 2,36, nicht 2,36-oder-2,35.
expect(runden(new Prisma.Decimal("2.355")).toFixed(2)).toBe("2.36");
expect(runden(new Prisma.Decimal("2.365")).toFixed(2)).toBe("2.37");
});
});
describe("Richtung", () => {
it("macht aus Art und positivem Betrag ein Vorzeichen", () => {
expect(alsWirkung("EINNAHME", "100.00").toFixed(2)).toBe("100.00");
expect(alsWirkung("AUSGABE", "100.00").toFixed(2)).toBe("-100.00");
});
});
describe("Bestand", () => {
it("ist Anfangsbestand plus die Wirkung aller Buchungen", () => {
const wert = bestand("250.00", [
{ art: "EINNAHME", betrag: "3860.00" },
{ art: "AUSGABE", betrag: "1420.60" },
]);
expect(wert.toFixed(2)).toBe("2689.40");
});
it("darf negativ werden — eine überzogene Barkasse ist ein Befund, kein Fehler", () => {
expect(bestand("10.00", [{ art: "AUSGABE", betrag: "50.00" }]).toFixed(2)).toBe("-40.00");
});
});
describe("Bilanz", () => {
it("trennt Einnahmen und Ausgaben und bildet den Saldo", () => {
const werte = bilanz([
{ art: "EINNAHME", betrag: "6500.00" },
{ art: "EINNAHME", betrag: "780.00" },
{ art: "AUSGABE", betrag: "1284.50" },
]);
expect(werte.einnahmen.toFixed(2)).toBe("7280.00");
expect(werte.ausgaben.toFixed(2)).toBe("1284.50");
expect(werte.saldo.toFixed(2)).toBe("5995.50");
});
});
describe("Positionssumme", () => {
it("rechnet Viertelstunden auf den Cent", () => {
// 0,25 × 47,50 = 11,875 — gerundet 11,88 und nicht 11,87.
expect(positionssumme("0.25", "47.50").toFixed(2)).toBe("11.88");
});
it("rechnet ganze Mengen ohne Rest", () => {
expect(positionssumme(9, "24.00").toFixed(2)).toBe("216.00");
});
});
describe("Eingaben lesen", () => {
it("versteht die deutsche Schreibweise", () => {
expect(betragLesen("1.234,56")?.toFixed(2)).toBe("1234.56");
expect(betragLesen("1.234,56 €")?.toFixed(2)).toBe("1234.56");
expect(betragLesen("12,50 EUR")?.toFixed(2)).toBe("12.50");
});
it("versteht die englische Schreibweise", () => {
expect(betragLesen("1,234.56")?.toFixed(2)).toBe("1234.56");
expect(betragLesen("$1234.56")?.toFixed(2)).toBe("1234.56");
});
it("liest auch Beträge ohne Trennzeichen", () => {
expect(betragLesen("42")?.toFixed(2)).toBe("42.00");
expect(betragLesen("-42,50")?.toFixed(2)).toBe("-42.50");
});
it("gibt bei Unlesbarem null zurück — und nicht null Euro", () => {
// Der Unterschied ist der ganze Punkt: null Euro ist eine Angabe,
// unlesbar ist eine Rückfrage an den, der es eingetippt hat.
expect(betragLesen("etwa zwanzig")).toBeNull();
expect(betragLesen("")).toBeNull();
expect(betragLesen("1,2,3")).toBeNull();
});
});
describe("Beträge schreiben", () => {
it("setzt Tausenderpunkte und ein Komma", () => {
expect(alsText("1234.56")).toBe("1.234,56 €");
expect(alsText("12.50")).toBe("12,50 €");
expect(alsText("1234567.89")).toBe("1.234.567,89 €");
});
it("verwendet das typografische Minus", () => {
expect(alsText("-3.20")).toBe("−3,20 €");
});
it("macht aus fehlenden Angaben einen Gedankenstrich", () => {
expect(alsText(null)).toBe("—");
expect(alsText(undefined)).toBe("—");
});
});
+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();
}
});
});
+200
View File
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
import {
STANDARDKATEGORIEN,
aehnlichkeit,
kategorieVorschlagen,
woerter,
} from "@/lib/kasse/vorschlag";
/**
* Der Kategorievorschlag.
*
* Sein Nutzen liegt nicht in den gesparten Sekunden, sondern darin, dass
* dieselbe Sache immer unter derselben Kategorie landet — erst das macht die
* Jahresauswertung brauchbar. Zwei Kategorien für Diesel, weil einmal
* „Kraftstoff" und einmal „Fahrzeuge" gewählt wurde, sind in der
* Jahreshauptversammlung nicht zu erklären.
*
* Ebenso wichtig ist, wann er schweigt: ein falscher Vorschlag wird
* übernommen, ohne gelesen zu werden. Deshalb die Schwelle bei der Hälfte
* gemeinsamer Wörter — und deshalb prüft dieser Test beide Seiten.
*/
const KATEGORIEN = [
{ id: "k-kraftstoff", name: "Kraftstoff", art: "AUSGABE" as const },
{ id: "k-reparatur", name: "Reparatur", art: "AUSGABE" as const },
{ id: "k-strom", name: "Wasser und Strom", art: "AUSGABE" as const },
{ id: "k-zuschuss", name: "Zuschüsse", art: "EINNAHME" as const },
{ id: "k-beitrag", name: "Mitgliedsbeiträge", art: "EINNAHME" as const },
];
describe("Wörter", () => {
it("wirft Füllwörter, kurze Wörter und reine Zahlen weg", () => {
expect(woerter("Rechnung Nr. 4711 vom 12.03. für die Feuerwehr GmbH")).toEqual([]);
});
it("behält, was unterscheidet", () => {
expect(woerter("Tankstelle Musterhausen Diesel LF 20")).toEqual([
"tankstelle",
"musterhausen",
"diesel",
]);
});
it("lässt Umlaute stehen", () => {
expect(woerter("Prüfung Drehleiter")).toEqual(["prüfung", "drehleiter"]);
});
});
describe("Ähnlichkeit", () => {
it("misst am kürzeren der beiden Texte", () => {
// "stadtwerke musterhausen" steckt vollständig in beiden.
const wert = aehnlichkeit(
"Stadtwerke Musterhausen Abschlag Strom",
"Stadtwerke Musterhausen Jahresabrechnung Strom und Wasser",
);
expect(wert).toBeGreaterThanOrEqual(0.5);
});
it("ergibt null bei nichts Gemeinsamem", () => {
expect(aehnlichkeit("Diesel Tankstelle", "Einsatzjacken Beschaffung")).toBe(0);
});
it("ergibt null, wenn nach dem Aussieben nichts übrig ist", () => {
expect(aehnlichkeit("Rechnung vom", "Rechnung Nr. 12")).toBe(0);
});
});
describe("Vorschlag aus dem eigenen Bestand", () => {
const vorbilder = [
{
kategorieId: "k-kraftstoff",
kategorieName: "Kraftstoff",
verwendungszweck: "Tankstelle Musterhausen Diesel LF 20 Sammelrechnung",
art: "AUSGABE" as const,
},
{
kategorieId: "k-reparatur",
kategorieName: "Reparatur",
verwendungszweck: "Werkstatt Müller Kupplung MTW",
art: "AUSGABE" as const,
},
];
it("nimmt die Kategorie, unter der dasselbe zuletzt gebucht wurde", () => {
const vorschlag = kategorieVorschlagen(
"Tankstelle Musterhausen Diesel MTW",
"AUSGABE",
vorbilder,
KATEGORIEN,
);
expect(vorschlag?.kategorieId).toBe("k-kraftstoff");
expect(vorschlag?.grund).toMatch(/zuletzt so gebucht/);
});
it("geht dem Bestand vor den Stichwörtern nach — die Wehr kennt sich selbst am besten", () => {
const eigen = [
{
kategorieId: "k-strom",
kategorieName: "Wasser und Strom",
// Diese Wehr bucht Diesel unter Wasser und Strom. Merkwürdig, aber
// ihre Entscheidung — und sie soll dabei bleiben dürfen.
verwendungszweck: "Tankstelle Musterhausen Diesel LF 20",
art: "AUSGABE" as const,
},
];
const vorschlag = kategorieVorschlagen(
"Tankstelle Musterhausen Diesel LF 20",
"AUSGABE",
eigen,
KATEGORIEN,
);
expect(vorschlag?.kategorieId).toBe("k-strom");
});
it("schweigt bei zufälliger Übereinstimmung, statt zu raten", () => {
const vorschlag = kategorieVorschlagen(
"Möbelhaus Sonnleitner Tischbeschaffung Schulungsraum",
"AUSGABE",
vorbilder,
KATEGORIEN,
);
expect(vorschlag).toBeNull();
});
it("mischt Einnahmen und Ausgaben nicht", () => {
const vorschlag = kategorieVorschlagen(
"Tankstelle Musterhausen Diesel LF 20 Sammelrechnung",
"EINNAHME",
vorbilder,
KATEGORIEN,
);
// Der Vorgänger ist eine Ausgabe und kommt deshalb nicht in Frage; ein
// Stichwort für Einnahmen trifft hier ebenso wenig.
expect(vorschlag?.kategorieId).not.toBe("k-kraftstoff");
});
});
describe("Vorschlag aus Stichwörtern", () => {
it("greift, solange es keinen Bestand gibt", () => {
const vorschlag = kategorieVorschlagen("Diesel für das LF 20", "AUSGABE", [], KATEGORIEN);
expect(vorschlag?.kategorieId).toBe("k-kraftstoff");
expect(vorschlag?.grund).toMatch(/Verwendungszweck/);
// Niedrigere Sicherheit als ein Treffer aus dem Bestand: ein Stichwort
// weiß nichts über die Gepflogenheiten dieser Wehr.
expect(vorschlag?.sicherheit).toBeLessThan(1);
});
it("erkennt den Gemeindezuschuss als Einnahme", () => {
const vorschlag = kategorieVorschlagen(
"Zuschuss der Gemeinde für den laufenden Betrieb",
"EINNAHME",
[],
KATEGORIEN,
);
expect(vorschlag?.kategorieId).toBe("k-zuschuss");
});
it("schlägt nichts vor, wenn es die Kategorie in dieser Wehr nicht gibt", () => {
const vorschlag = kategorieVorschlagen("Neue Einsatzjacken", "AUSGABE", [], KATEGORIEN);
expect(vorschlag).toBeNull();
});
it("schweigt bei leerem Verwendungszweck", () => {
expect(kategorieVorschlagen(" ", "AUSGABE", [], KATEGORIEN)).toBeNull();
});
});
describe("Standardkontenrahmen", () => {
it("verweist nur auf Oberkategorien, die es auch gibt", () => {
const namen = new Set(STANDARDKATEGORIEN.map((eintrag) => eintrag.name));
for (const eintrag of STANDARDKATEGORIEN) {
if (eintrag.oberkategorie) {
expect(namen.has(eintrag.oberkategorie)).toBe(true);
}
}
});
it("gibt jeden Namen nur einmal — die Datenbank verlangt es so", () => {
const namen = STANDARDKATEGORIEN.map((eintrag) => eintrag.name);
expect(new Set(namen).size).toBe(namen.length);
});
it("stellt jede Unterkategorie in eine Oberkategorie derselben Richtung", () => {
const nachName = new Map(STANDARDKATEGORIEN.map((eintrag) => [eintrag.name, eintrag]));
for (const eintrag of STANDARDKATEGORIEN) {
if (eintrag.oberkategorie) {
expect(nachName.get(eintrag.oberkategorie)?.art).toBe(eintrag.art);
}
}
});
});