Extract Comdirect CSV parsing helpers
This commit is contained in:
@@ -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,
|
||||
|
||||
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