Extract Comdirect CSV parsing helpers

This commit is contained in:
Matthias
2026-06-16 12:04:48 +02:00
parent 7ed9b521c4
commit fba74ec217
4 changed files with 191 additions and 47 deletions

View File

@@ -0,0 +1,43 @@
---
id: TASK-3
title: Repariere CSV-Import-Pipeline
status: In Progress
assignee: []
created_date: '2026-06-15 18:10'
updated_date: '2026-06-15 18:13'
labels: []
dependencies: []
priority: high
ordinal: 3000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
CSV-Dateien aus Bankexporten werden im Import-Preview nicht als Daten angezeigt. Die Importstrecke soll das vorliegende Format erkennen, Header-/Metazeilen überspringen und Transaktionen ins interne Format übersetzen.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 CSV-Dateien mit Metazeilen vor der Kopfzeile werden korrekt eingelesen
- [x] #2 Deutsche Zahlenformate mit Dezimalkomma werden korrekt als Beträge geparst
- [x] #3 Import-Preview zeigt Datum, Beschreibung, Betrag, Kategorie und Monat für erkannte Transaktionen
- [x] #4 Parser-Verhalten ist durch Tests oder einen reproduzierbaren Check abgesichert
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. CSV-Import-Code und erwartetes Datenformat lokalisieren
2. Reproduzierbaren Parser-Test mit Bankexport-Metazeilen schreiben
3. Parser minimal erweitern: Header erkennen, deutsche Beträge und Datum normalisieren
4. Import-Preview gegen interne Transaktionsform prüfen
5. Tests/Checks ausführen und Task-Notizen aktualisieren
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: Parser hat nur exakt "Wertstellung (Valuta)" erkannt, der beobachtete Export nutzt "Wertstellung". Danach hätten zweistellige Daten wie 15.06.26 mit dd.MM.yyyy falsch als Jahr 0026 geparst.
Umsetzung: CSV-Parsing in testbares Modul extrahiert, Header-Erkennung toleranter gemacht, Metazeilen übersprungen und deutsche 2-/4-stellige Datumswerte normalisiert.
<!-- SECTION:NOTES:END -->

View File

@@ -1,6 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { parse, type ParseResult } from "papaparse";
import { format, parse as parseDate } from "date-fns";
import { useMutation, useQuery } from "convex/react"; import { useMutation, useQuery } from "convex/react";
import { api } from "../../../convex/_generated/api"; import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel"; import type { Id } from "../../../convex/_generated/dataModel";
@@ -11,45 +9,9 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ImportPreviewTable, type PreviewRow } from "./ImportPreviewTable"; import { ImportPreviewTable, type PreviewRow } from "./ImportPreviewTable";
import { parseComdirectCsv, parseComdirectDate } from "./csvParser";
import { toast } from "sonner"; import { toast } from "sonner";
const HEADER = "Buchungstag;Wertstellung (Valuta);Vorgang;Buchungstext;Umsatz in EUR";
function parseComdirectCsv(text: string): Array<Record<string, string>> {
const lines = text.split(/\r?\n/);
const rows: Array<Record<string, string>> = [];
let inBlock = false;
for (const line of lines) {
if (line.trim() === HEADER) {
inBlock = true;
continue;
}
if (!inBlock) continue;
if (
!line.trim() ||
line.startsWith("Alter Kontostand") ||
line.startsWith("Neuer Kontostand") ||
line.startsWith("Umsätze") ||
line.startsWith("Keine Umsätze")
) {
inBlock = false;
continue;
}
const parsed: ParseResult<string[]> = parse(line, { delimiter: ";", quoteChar: '"' });
const fields = parsed.data[0];
if (!fields || fields.length < 5) continue;
rows.push({
buchungstag: fields[0],
valuta: fields[1],
vorgang: fields[2],
buchungstext: fields[3],
betrag: fields[4],
});
}
return rows;
}
export function CsvImportWizard() { export function CsvImportWizard() {
const accounts = useQuery(api.accounts.list); const accounts = useQuery(api.accounts.list);
const settings = useQuery(api.settings.get); const settings = useQuery(api.settings.get);
@@ -77,11 +39,7 @@ export function CsvImportWizard() {
const preview: PreviewRow[] = []; const preview: PreviewRow[] = [];
for (const row of parsed) { for (const row of parsed) {
const isPending = row.buchungstag.trim().toLowerCase() === "offen"; const isPending = row.buchungstag.trim().toLowerCase() === "offen";
let bookingDate: string | undefined; const bookingDate = parseComdirectDate(row.buchungstag);
if (!isPending) {
const d = parseDate(row.buchungstag, "dd.MM.yyyy", new Date());
bookingDate = format(d, "yyyy-MM-dd");
}
const amount = parseGermanAmount(row.betrag); const amount = parseGermanAmount(row.betrag);
const { counterparty, description, rawText } = parseCounterpartyFromBuchungstext(row.buchungstext); const { counterparty, description, rawText } = parseCounterpartyFromBuchungstext(row.buchungstext);
const categoryName = categorize(rawText, amount, row.vorgang, ownNames); const categoryName = categorize(rawText, amount, row.vorgang, ownNames);
@@ -100,9 +58,7 @@ export function CsvImportWizard() {
}); });
preview.push({ preview.push({
bookingDate, bookingDate,
valueDate: row.valuta valueDate: parseComdirectDate(row.valuta),
? format(parseDate(row.valuta, "dd.MM.yyyy", new Date()), "yyyy-MM-dd")
: undefined,
description, description,
counterparty, counterparty,
amount, amount,

View File

@@ -0,0 +1,48 @@
import { describe, expect, test } from "vitest";
import { parseComdirectCsv, parseComdirectDate } from "./csvParser";
describe("CSV import parser", () => {
test("parses comdirect exports with metadata rows and Wertstellung header", () => {
const csv = [
"Umsätze Girokonto;Zeitraum: 15.06.2024 - 15.06.2026",
"Neuer Kontostand;-4.031,25 EUR",
"",
"Buchungstag;Wertstellung;Vorgang;Buchungstext;Umsatz in EUR",
"offen;--;Kartenverfügung;Kto/IBAN: 1111 Buchungstext: Reservierung;-35",
"15.06.26;15.06.26;<übertrag / gutschrift>;Empfänger: Max Mustermann Buchungstext: Testzahlung;-174,3",
"15.06.26;15.06.26;Lastschrift / Belastung;Auftraggeber: Stadtwerke Buchungstext: Abschlag;-2,89",
"",
].join("\n");
expect(parseComdirectCsv(csv)).toEqual([
{
buchungstag: "offen",
valuta: "",
vorgang: "Kartenverfügung",
buchungstext: "Kto/IBAN: 1111 Buchungstext: Reservierung",
betrag: "-35",
},
{
buchungstag: "15.06.26",
valuta: "15.06.26",
vorgang: "<übertrag / gutschrift>",
buchungstext: "Empfänger: Max Mustermann Buchungstext: Testzahlung",
betrag: "-174,3",
},
{
buchungstag: "15.06.26",
valuta: "15.06.26",
vorgang: "Lastschrift / Belastung",
buchungstext: "Auftraggeber: Stadtwerke Buchungstext: Abschlag",
betrag: "-2,89",
},
]);
});
test("normalizes German two-digit and four-digit dates", () => {
expect(parseComdirectDate("15.06.26")).toBe("2026-06-15");
expect(parseComdirectDate("15.06.2026")).toBe("2026-06-15");
expect(parseComdirectDate("--")).toBeUndefined();
expect(parseComdirectDate("offen")).toBeUndefined();
});
});

View File

@@ -0,0 +1,97 @@
import { format, isValid, parse as parseDate } from "date-fns";
import { parse, type ParseResult } from "papaparse";
export type ComdirectCsvRow = {
buchungstag: string;
valuta: string;
vorgang: string;
buchungstext: string;
betrag: string;
};
const FOOTER_PREFIXES = [
"Alter Kontostand",
"Neuer Kontostand",
"Umsätze",
"Keine Umsätze",
];
function normalizeHeader(value: string): string {
return value
.replace(/\uFEFF/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function isTransactionHeader(fields: string[]): boolean {
const normalized = fields.map(normalizeHeader);
return (
normalized[0] === "buchungstag" &&
(normalized[1] === "wertstellung" || normalized[1] === "wertstellung (valuta)") &&
normalized[2] === "vorgang" &&
normalized[3] === "buchungstext" &&
normalized[4] === "umsatz in eur"
);
}
function isFooter(fields: string[]): boolean {
const firstCell = fields[0]?.trim() ?? "";
return FOOTER_PREFIXES.some((prefix) => firstCell.startsWith(prefix));
}
function normalizeValuta(value: string): string {
const trimmed = value.trim();
return trimmed === "--" ? "" : trimmed;
}
export function parseComdirectCsv(text: string): ComdirectCsvRow[] {
const parsed: ParseResult<string[]> = parse(text, {
delimiter: ";",
quoteChar: '"',
skipEmptyLines: false,
});
const rows: ComdirectCsvRow[] = [];
let inTransactionBlock = false;
for (const fields of parsed.data) {
if (!fields?.length || fields.every((field) => !field.trim())) {
if (inTransactionBlock) break;
continue;
}
if (!inTransactionBlock) {
inTransactionBlock = isTransactionHeader(fields);
continue;
}
if (isFooter(fields)) break;
if (fields.length < 5) continue;
rows.push({
buchungstag: fields[0].trim(),
valuta: normalizeValuta(fields[1]),
vorgang: fields[2].trim(),
buchungstext: fields[3].trim(),
betrag: fields[4].trim(),
});
}
return rows;
}
export function parseComdirectDate(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed || trimmed === "--" || trimmed.toLowerCase() === "offen") {
return undefined;
}
const match = trimmed.match(/^(\d{2})\.(\d{2})\.(\d{2}|\d{4})$/);
if (match) {
const pattern = match[3].length === 2 ? "dd.MM.yy" : "dd.MM.yyyy";
const date = parseDate(trimmed, pattern, new Date());
if (isValid(date)) return format(date, "yyyy-MM-dd");
}
return undefined;
}