Files
FireAdministration/tests/unit/kasse-bankimport.test.ts

219 lines
7.8 KiB
TypeScript

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