Extract Comdirect CSV parsing helpers
This commit is contained in:
43
backlog/tasks/task-3 - Repariere-CSV-Import-Pipeline.md
Normal file
43
backlog/tasks/task-3 - Repariere-CSV-Import-Pipeline.md
Normal 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 -->
|
||||||
@@ -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,
|
||||||
|
|||||||
48
src/components/import/csvParser.test.ts
Normal file
48
src/components/import/csvParser.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
97
src/components/import/csvParser.ts
Normal file
97
src/components/import/csvParser.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user