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
+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);
}
}
});
});