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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user