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:
ZProger
2026-08-25 02:44:30 +03:00
parent 94782ee073
commit 19f5cfa429
11 changed files with 706 additions and 8 deletions

View File

@@ -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",