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:
co-authored by
Claude Opus 5
parent
d70acad4cd
commit
4165334c5a
@@ -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