diff --git a/backlog/tasks/task-3 - Repariere-CSV-Import-Pipeline.md b/backlog/tasks/task-3 - Repariere-CSV-Import-Pipeline.md new file mode 100644 index 0000000..d5ab588 --- /dev/null +++ b/backlog/tasks/task-3 - Repariere-CSV-Import-Pipeline.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Implementation Plan + + +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 + + +## Implementation Notes + + +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. + diff --git a/src/components/import/CsvImportWizard.tsx b/src/components/import/CsvImportWizard.tsx index 225a3b6..24e429a 100644 --- a/src/components/import/CsvImportWizard.tsx +++ b/src/components/import/CsvImportWizard.tsx @@ -1,6 +1,4 @@ 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 { api } from "../../../convex/_generated/api"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ImportPreviewTable, type PreviewRow } from "./ImportPreviewTable"; +import { parseComdirectCsv, parseComdirectDate } from "./csvParser"; import { toast } from "sonner"; -const HEADER = "Buchungstag;Wertstellung (Valuta);Vorgang;Buchungstext;Umsatz in EUR"; - -function parseComdirectCsv(text: string): Array> { - const lines = text.split(/\r?\n/); - const rows: Array> = []; - 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 = 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() { const accounts = useQuery(api.accounts.list); const settings = useQuery(api.settings.get); @@ -77,11 +39,7 @@ export function CsvImportWizard() { const preview: PreviewRow[] = []; for (const row of parsed) { const isPending = row.buchungstag.trim().toLowerCase() === "offen"; - let bookingDate: string | undefined; - if (!isPending) { - const d = parseDate(row.buchungstag, "dd.MM.yyyy", new Date()); - bookingDate = format(d, "yyyy-MM-dd"); - } + const bookingDate = parseComdirectDate(row.buchungstag); const amount = parseGermanAmount(row.betrag); const { counterparty, description, rawText } = parseCounterpartyFromBuchungstext(row.buchungstext); const categoryName = categorize(rawText, amount, row.vorgang, ownNames); @@ -100,9 +58,7 @@ export function CsvImportWizard() { }); preview.push({ bookingDate, - valueDate: row.valuta - ? format(parseDate(row.valuta, "dd.MM.yyyy", new Date()), "yyyy-MM-dd") - : undefined, + valueDate: parseComdirectDate(row.valuta), description, counterparty, amount, diff --git a/src/components/import/csvParser.test.ts b/src/components/import/csvParser.test.ts new file mode 100644 index 0000000..ff9ff5d --- /dev/null +++ b/src/components/import/csvParser.test.ts @@ -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(); + }); +}); diff --git a/src/components/import/csvParser.ts b/src/components/import/csvParser.ts new file mode 100644 index 0000000..c4e18cc --- /dev/null +++ b/src/components/import/csvParser.ts @@ -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 = 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; +}