mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 19:16:40 +07:00
feat: import transactions from a CSV bank statement
Fourth of the four ideas picked from the Maybe/Sure feature audit, and the last one queued up. A 3-step wizard (account -> column mapping -> preview) at /transactions/import, parsed entirely client-side (new lib/csv.ts — delimiter auto-detect, 5 date formats, thousands/decimal-separator-aware amounts) and submitted through a new all-or-nothing POST /transactions/bulk endpoint, mirroring backup restore's validate-everything-before-touching- anything approach. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
106
frontend/src/lib/csv.ts
Normal file
106
frontend/src/lib/csv.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// Minimal RFC4180-ish CSV parser — handles quoted fields (with embedded
|
||||
// delimiters/newlines, "" as an escaped quote) and auto-detects comma vs
|
||||
// semicolon as the delimiter (many EU bank exports use ";"). No dependency
|
||||
// pulled in for this; bank CSV exports don't need more than this covers.
|
||||
export function parseCsv(text: string): string[][] {
|
||||
const firstLine = text.slice(0, text.search(/\r?\n/) === -1 ? text.length : text.search(/\r?\n/));
|
||||
const commaCount = (firstLine.match(/,/g) ?? []).length;
|
||||
const semicolonCount = (firstLine.match(/;/g) ?? []).length;
|
||||
const delimiter = semicolonCount > commaCount ? ";" : ",";
|
||||
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 1;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = true;
|
||||
} else if (char === delimiter) {
|
||||
row.push(field);
|
||||
field = "";
|
||||
} else if (char === "\r") {
|
||||
// skip — \n (below) closes the row
|
||||
} else if (char === "\n") {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = "";
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows.filter((cells) => !(cells.length === 1 && cells[0].trim() === ""));
|
||||
}
|
||||
|
||||
export const DATE_FORMATS = ["YYYY-MM-DD", "DD.MM.YYYY", "DD/MM/YYYY", "MM/DD/YYYY", "DD-MM-YYYY"] as const;
|
||||
export type DateFormat = (typeof DATE_FORMATS)[number];
|
||||
|
||||
const DATE_PATTERNS: Record<DateFormat, { regex: RegExp; order: ["y" | "m" | "d", "y" | "m" | "d", "y" | "m" | "d"] }> = {
|
||||
"YYYY-MM-DD": { regex: /^(\d{4})-(\d{1,2})-(\d{1,2})$/, order: ["y", "m", "d"] },
|
||||
"DD.MM.YYYY": { regex: /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/, order: ["d", "m", "y"] },
|
||||
"DD/MM/YYYY": { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, order: ["d", "m", "y"] },
|
||||
"MM/DD/YYYY": { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, order: ["m", "d", "y"] },
|
||||
"DD-MM-YYYY": { regex: /^(\d{1,2})-(\d{1,2})-(\d{4})$/, order: ["d", "m", "y"] },
|
||||
};
|
||||
|
||||
/** Parses `raw` per `format`, returning an ISO "YYYY-MM-DD" string, or null
|
||||
* if it doesn't match the pattern or isn't a real calendar date (e.g. Feb 30
|
||||
* round-trips to Mar 2, which this catches by re-checking the parts). */
|
||||
export function parseDateWithFormat(raw: string, format: DateFormat): string | null {
|
||||
const { regex, order } = DATE_PATTERNS[format];
|
||||
const match = raw.trim().match(regex);
|
||||
if (!match) return null;
|
||||
|
||||
const parts: Record<"y" | "m" | "d", number> = { y: 0, m: 0, d: 0 };
|
||||
order.forEach((key, index) => {
|
||||
parts[key] = Number(match[index + 1]);
|
||||
});
|
||||
const { y: year, m: month, d: day } = parts;
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
|
||||
|
||||
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Parses a bank-export amount string — handles thousands separators and
|
||||
* either "," or "." as the decimal mark by assuming whichever comes last is
|
||||
* the decimal separator (e.g. "1.234,56" and "1,234.56" both work). */
|
||||
export function parseAmount(raw: string): number | null {
|
||||
let value = raw.trim().replace(/[\s ]/g, "");
|
||||
if (!value) return null;
|
||||
|
||||
const hasComma = value.includes(",");
|
||||
const hasDot = value.includes(".");
|
||||
if (hasComma && hasDot) {
|
||||
value = value.lastIndexOf(",") > value.lastIndexOf(".") ? value.replace(/\./g, "").replace(",", ".") : value.replace(/,/g, "");
|
||||
} else if (hasComma) {
|
||||
value = value.replace(",", ".");
|
||||
}
|
||||
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
@@ -133,6 +133,37 @@ const ru = {
|
||||
"netWorth.form.saveError": "Не удалось сохранить актив. Проверьте данные и попробуйте снова.",
|
||||
|
||||
"transactions.addButton": "Добавить",
|
||||
"transactions.importButton": "Импорт CSV",
|
||||
"transactions.import.title": "Импорт транзакций из CSV",
|
||||
"transactions.import.accountLabel": "Счёт для импорта",
|
||||
"transactions.import.accountHint": "Все транзакции из файла будут добавлены на этот счёт.",
|
||||
"transactions.import.uploadTitle": "Файл",
|
||||
"transactions.import.uploadHint": "Выгрузка транзакций из банка или другого приложения в формате CSV (с заголовками колонок в первой строке).",
|
||||
"transactions.import.chooseFile": "Выбрать файл",
|
||||
"transactions.import.pickAccountFirst": "Сначала выберите счёт.",
|
||||
"transactions.import.errorEmptyFile": "В файле нет ни одной строки с данными (кроме заголовка).",
|
||||
"transactions.import.mapTitle": "Сопоставьте колонки",
|
||||
"transactions.import.dateColumnLabel": "Колонка с датой",
|
||||
"transactions.import.dateFormatLabel": "Формат даты",
|
||||
"transactions.import.selectColumn": "Выберите колонку…",
|
||||
"transactions.import.amountColumnLabel": "Колонка с суммой",
|
||||
"transactions.import.amountHint": "Отрицательные суммы — расход, положительные — доход.",
|
||||
"transactions.import.descriptionColumnLabel": "Колонка с описанием",
|
||||
"transactions.import.notMapped": "— не сопоставлено —",
|
||||
"transactions.import.categoryHint": "Если текст в колонке точно совпадёт с названием существующей категории — она подставится автоматически.",
|
||||
"transactions.import.startOver": "Начать заново",
|
||||
"transactions.import.previewButton": "Показать превью",
|
||||
"transactions.import.previewTitle": "Превью импорта",
|
||||
"transactions.import.summary": "Готово к импорту: {{valid}}. Пропущено из-за ошибок: {{skipped}}.",
|
||||
"transactions.import.andMore": "…и ещё {{count}}",
|
||||
"transactions.import.skippedRow": "Строка {{row}}: {{reason}}",
|
||||
"transactions.import.importButton": "Импортировать {{count}}",
|
||||
"transactions.import.importSuccess": "Готово — добавлено транзакций: {{count}}.",
|
||||
"transactions.import.goToTransactions": "К транзакциям",
|
||||
"transactions.import.importError": "Не удалось импортировать. Проверьте данные и попробуйте снова.",
|
||||
"transactions.import.errorBadDate": "не удалось распознать дату «{{value}}»",
|
||||
"transactions.import.errorBadAmount": "не удалось распознать сумму «{{value}}»",
|
||||
"transactions.import.errorNoDescription": "нет описания",
|
||||
"transactions.allTypes": "Все типы",
|
||||
"transactions.expense": "Расходы",
|
||||
"transactions.income": "Доходы",
|
||||
@@ -501,6 +532,37 @@ const en: Record<keyof typeof ru, string> = {
|
||||
"netWorth.form.saveError": "Failed to save the asset. Check the data and try again.",
|
||||
|
||||
"transactions.addButton": "Add",
|
||||
"transactions.importButton": "Import CSV",
|
||||
"transactions.import.title": "Import transactions from CSV",
|
||||
"transactions.import.accountLabel": "Account to import into",
|
||||
"transactions.import.accountHint": "Every transaction from the file will be added to this account.",
|
||||
"transactions.import.uploadTitle": "File",
|
||||
"transactions.import.uploadHint": "A transaction export from your bank or another app, in CSV format (with column headers in the first row).",
|
||||
"transactions.import.chooseFile": "Choose file",
|
||||
"transactions.import.pickAccountFirst": "Pick an account first.",
|
||||
"transactions.import.errorEmptyFile": "The file has no data rows (besides the header).",
|
||||
"transactions.import.mapTitle": "Map the columns",
|
||||
"transactions.import.dateColumnLabel": "Date column",
|
||||
"transactions.import.dateFormatLabel": "Date format",
|
||||
"transactions.import.selectColumn": "Select a column…",
|
||||
"transactions.import.amountColumnLabel": "Amount column",
|
||||
"transactions.import.amountHint": "Negative amounts are expenses, positive are income.",
|
||||
"transactions.import.descriptionColumnLabel": "Description column",
|
||||
"transactions.import.notMapped": "— not mapped —",
|
||||
"transactions.import.categoryHint": "If the column's text exactly matches an existing category name, it's filled in automatically.",
|
||||
"transactions.import.startOver": "Start over",
|
||||
"transactions.import.previewButton": "Show preview",
|
||||
"transactions.import.previewTitle": "Import preview",
|
||||
"transactions.import.summary": "Ready to import: {{valid}}. Skipped due to errors: {{skipped}}.",
|
||||
"transactions.import.andMore": "…and {{count}} more",
|
||||
"transactions.import.skippedRow": "Row {{row}}: {{reason}}",
|
||||
"transactions.import.importButton": "Import {{count}}",
|
||||
"transactions.import.importSuccess": "Done — {{count}} transactions added.",
|
||||
"transactions.import.goToTransactions": "Go to transactions",
|
||||
"transactions.import.importError": "Import failed. Check the data and try again.",
|
||||
"transactions.import.errorBadDate": "couldn't parse the date \"{{value}}\"",
|
||||
"transactions.import.errorBadAmount": "couldn't parse the amount \"{{value}}\"",
|
||||
"transactions.import.errorNoDescription": "no description",
|
||||
"transactions.allTypes": "All types",
|
||||
"transactions.expense": "Expenses",
|
||||
"transactions.income": "Income",
|
||||
|
||||
Reference in New Issue
Block a user