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

@@ -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<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() {
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,

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