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