219 lines
7.8 KiB
TypeScript
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/);
|
|
});
|
|
});
|