diff --git a/DOCS.md b/DOCS.md index 8aa0691..412389f 100644 --- a/DOCS.md +++ b/DOCS.md @@ -368,6 +368,16 @@ Used by the CSV import wizard, but callable directly for any bulk load (e.g. syn export tool). All rows are validated **before** any is inserted — one bad row fails the whole request with no partial import. +The wizard itself (`/transactions/import`) is bank-agnostic: it auto-detects delimiter and +encoding and lets you map columns by hand. On top of that it ships **bank profiles** that pre-fill +the mapping and formats when the header row matches a known export (or when you pick the bank +explicitly): **T-Bank** (semicolon CSV, `Сумма платежа`, rows with `Статус ≠ OK` skipped), +**monobank** (Ukrainian-language CSV, `Сума в валюті картки (…)`) and **PrivatBank** +(`Сума в валюті картки`, `Опис операції`). Profiles live in `frontend/src/lib/bankPresets.ts` — +adding a bank is one declarative entry (header matchers, date/amount format, optional row filter). +Banks that only export PDF/XLSX (Sber, Kaspi, Halyk, maib…) are imported via a third-party +PDF→CSV converter and the manual mapping. + ```json { "items": [ /* 1–5000 TransactionCreate objects */ ] } ``` @@ -768,7 +778,7 @@ Single row, created automatically on first run — there's nothing to create, on "risky_allocation_threshold_percent": 20, "idle_cash_threshold_amount": "1000.00", "idle_cash_threshold_days": 60, - "app_version": "1.1.7" + "app_version": "1.1.8" } ``` diff --git a/README.md b/README.md index 57f619e..f1c0be3 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Aurum treats your financial life as a **system**, not a spreadsheet. ## 🧩 Core Features ### 💰 Cash Flow & Transactions -Log income, expenses, and transfers across as many accounts as you want, organized into categories (with one level of subcategories) plus free-form tags, and searchable. One purchase spanning multiple subcategories of the same parent (a grocery receipt part "Sweets", part "Alcohol") can be recorded as a single split transaction instead of several — every report and chart still counts each share under its own category. Already have history elsewhere? Import a bank's CSV export in a guided 3-step wizard instead of typing every line by hand. A dedicated Cash Flow view charts income vs. expense month by month. +Log income, expenses, and transfers across as many accounts as you want, organized into categories (with one level of subcategories) plus free-form tags, and searchable. One purchase spanning multiple subcategories of the same parent (a grocery receipt part "Sweets", part "Alcohol") can be recorded as a single split transaction instead of several — every report and chart still counts each share under its own category. Already have history elsewhere? Import a bank's CSV export in a guided 3-step wizard instead of typing every line by hand — with built-in profiles for T-Bank, monobank and PrivatBank exports (columns, date/amount formats and declined-payment rows handled automatically), and manual column mapping for everything else. A dedicated Cash Flow view charts income vs. expense month by month. ### 📈 Net Worth Engine A live net worth timeline (30 days to all-time) aggregating cash and every manually tracked asset — investments, crypto, real estate, vehicles, precious metals — into one number, with a full breakdown by asset class, by how each asset behaves (income / neutral / drain), and by risk level. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2ed65b9..c2f06ff 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -8,7 +8,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict # it in Settings; it deliberately does NOT ride on the unauthenticated # /api/health), and embedded in exported backups so an old file can be told # apart from a current one. -APP_VERSION = "1.1.7" +APP_VERSION = "1.1.8" class Settings(BaseSettings): diff --git a/frontend/package.json b/frontend/package.json index 56444eb..dbba1c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "1.1.7", + "version": "1.1.8", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/lib/bankPresets.test.ts b/frontend/src/lib/bankPresets.test.ts new file mode 100644 index 0000000..77f80dd --- /dev/null +++ b/frontend/src/lib/bankPresets.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { applyPresetMapping, detectPreset, findPreset, normalizeHeader, rowFilterRejection, type ImportMapping } from "@/lib/bankPresets"; +import { parseAmount, parseCsv, parseDateWithFormat } from "@/lib/csv"; + +const EMPTY: ImportMapping = { date: "", amount: "", description: "", merchant: "", notes: "", category: "" }; + +// Header rows as the banks actually emit them (see the per-preset comments in +// lib/bankPresets.ts for where each export comes from). +const TBANK_CSV = [ + '"Дата операции";"Дата платежа";"Номер карты";"Статус";"Сумма операции";"Валюта операции";"Сумма платежа";"Валюта платежа";"Кэшбэк";"Категория";"MCC";"Описание";"Бонусы (включая кэшбэк)"', + '"24.10.2018 17:04:25";"24.10.2018";"*8305";"OK";"-882,04";"RUB";"-882,04";"RUB";"";"Переводы/иб";"";"Пополнение счета Тинькофф Брокер";"0,00"', + '"25.10.2018 09:12:00";"25.10.2018";"*8305";"FAILED";"-1500,00";"RUB";"-1500,00";"RUB";"";"Супермаркеты";"5411";"Пятёрочка";"0,00"', +].join("\n"); + +const MONOBANK_CSV = [ + // Latin "i" in "Дата i час" is deliberate — that's what the real file has. + '"Дата i час операції","Деталі операції",MCC,"Сума в валюті картки (UAH)","Сума в валюті операції",Валюта,Курс,"Сума комісій (UAH)","Сума кешбеку (UAH)","Залишок після операції"', + '"01.03.2024 12:30:45","Сільпо",5411,-250.50,-250.50,UAH,—,—,2.50,10000.00', +].join("\n"); + +const PRIVAT_CSV = [ + "Дата;Час;Категорія;Картка;Опис операції;Сума в валюті картки;Валюта картки;Сума в валюті транзакції;Валюта транзакції;Залишок на кінець періоду;Валюта залишку", + "05.02.2024;14:05;Продукти;5168****1234;АТБ;-320,00;UAH;-320,00;UAH;1500,00;UAH", +].join("\n"); + +describe("normalizeHeader", () => { + it("folds case, whitespace, BOM and the Latin i", () => { + expect(normalizeHeader(" Дата i час ")).toBe("дата і час"); + }); +}); + +describe("detectPreset", () => { + it("recognises T-Bank, monobank and PrivatBank header rows", () => { + expect(detectPreset(parseCsv(TBANK_CSV)[0])?.id).toBe("tbank"); + expect(detectPreset(parseCsv(MONOBANK_CSV)[0])?.id).toBe("monobank"); + expect(detectPreset(parseCsv(PRIVAT_CSV)[0])?.id).toBe("privatbank"); + }); + + it("returns null for a generic export", () => { + expect(detectPreset(["Date", "Amount", "Description"])).toBeNull(); + }); +}); + +describe("applyPresetMapping", () => { + it("maps T-Bank onto the card-currency amount and keeps the category", () => { + const headers = parseCsv(TBANK_CSV)[0]; + const mapping = applyPresetMapping(findPreset("tbank")!, headers, EMPTY); + expect(mapping).toMatchObject({ date: "Дата операции", amount: "Сумма платежа", description: "Описание", category: "Категория" }); + }); + + it("matches monobank's currency-suffixed amount header by prefix", () => { + const headers = parseCsv(MONOBANK_CSV)[0]; + const mapping = applyPresetMapping(findPreset("monobank")!, headers, EMPTY); + expect(mapping.amount).toBe("Сума в валюті картки (UAH)"); + expect(mapping.date).toBe("Дата i час операції"); + }); + + it("leaves fields the preset doesn't cover on the fallback", () => { + const headers = parseCsv(PRIVAT_CSV)[0]; + const mapping = applyPresetMapping(findPreset("privatbank")!, headers, { ...EMPTY, notes: "Картка" }); + expect(mapping.notes).toBe("Картка"); + expect(mapping.category).toBe("Категорія"); + }); +}); + +describe("rowFilterRejection", () => { + it("drops T-Bank rows whose status is not OK and reports the status", () => { + const [headers, ok, failed] = parseCsv(TBANK_CSV); + const preset = findPreset("tbank")!; + expect(rowFilterRejection(preset, headers, ok)).toBeNull(); + expect(rowFilterRejection(preset, headers, failed)).toBe("FAILED"); + }); + + it("keeps everything when the preset has no filter or the column is absent", () => { + const [headers, row] = parseCsv(MONOBANK_CSV); + expect(rowFilterRejection(findPreset("monobank")!, headers, row)).toBeNull(); + expect(rowFilterRejection(findPreset("tbank")!, ["Дата"], ["01.01.2024"])).toBeNull(); + expect(rowFilterRejection(null, headers, row)).toBeNull(); + }); +}); + +describe("preset formats against real sample rows", () => { + it("parses T-Bank's timestamped date and comma-decimal amount", () => { + const [, row] = parseCsv(TBANK_CSV); + const preset = findPreset("tbank")!; + expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2018-10-24"); + expect(parseAmount(row[6], preset.amountFormat)).toBe(-882.04); + }); + + it("parses monobank's timestamped date and dot-decimal amount", () => { + const [, row] = parseCsv(MONOBANK_CSV); + const preset = findPreset("monobank")!; + expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2024-03-01"); + expect(parseAmount(row[3], preset.amountFormat)).toBe(-250.5); + // monobank writes empty cells as an em dash — must not parse as 0. + expect(parseAmount(row[6], preset.amountFormat)).toBeNull(); + }); + + it("parses PrivatBank's plain date and comma amount", () => { + const [, row] = parseCsv(PRIVAT_CSV); + const preset = findPreset("privatbank")!; + expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2024-02-05"); + expect(parseAmount(row[5], preset.amountFormat)).toBe(-320); + }); +}); + +describe("parseDateWithFormat with a time suffix", () => { + it("accepts an optional trailing time in every format", () => { + expect(parseDateWithFormat("2024-01-05T09:30", "YYYY-MM-DD")).toBe("2024-01-05"); + expect(parseDateWithFormat("05/01/2024 09:30:15", "DD/MM/YYYY")).toBe("2024-01-05"); + expect(parseDateWithFormat("01/05/2024 9:30", "MM/DD/YYYY")).toBe("2024-01-05"); + expect(parseDateWithFormat("05-01-2024 09:30", "DD-MM-YYYY")).toBe("2024-01-05"); + }); + + it("still rejects trailing garbage that isn't a time", () => { + expect(parseDateWithFormat("05.01.2024 abc", "DD.MM.YYYY")).toBeNull(); + expect(parseDateWithFormat("05.01.2024 09", "DD.MM.YYYY")).toBeNull(); + }); +}); diff --git a/frontend/src/lib/bankPresets.ts b/frontend/src/lib/bankPresets.ts new file mode 100644 index 0000000..ecccd96 --- /dev/null +++ b/frontend/src/lib/bankPresets.ts @@ -0,0 +1,152 @@ +// Bank-specific import presets for the CSV wizard (pages/CsvImportPage.tsx). +// +// A preset is nothing more than a pre-filled answer to the wizard's "map" +// step: which column is the date, which is the amount, what the date/amount +// formats are, and — the one thing the generic mapping can't express — which +// rows to drop (a declined card payment still shows up in the export). The +// wizard itself stays bank-agnostic: presets only feed it defaults, and the +// user can still override any of them on the map screen. +// +// Only banks whose personal-account CSV export has a known, stable header +// layout are listed. Banks that export PDF/XLSX only (Sber, Kaspi, Halyk, +// maib…) go through the generic mapping via a third-party converter instead. +import type { AmountFormat, DateFormat } from "@/lib/csv"; + +/** Column mapping as the wizard understands it — header *names*, not + * indexes, so re-decoding the file under another encoding can re-resolve + * them. Empty string = not mapped. */ +export interface ImportMapping { + date: string; + amount: string; + description: string; + merchant: string; + notes: string; + category: string; +} + +/** Matches a header cell by normalised prefix — see normalizeHeader(). A + * prefix, not equality, because some banks put the account currency in the + * header itself ("Сума в валюті картки (UAH)") and it changes per card. */ +export type HeaderMatcher = string[]; + +export interface BankPreset { + id: string; + /** Proper name, shown as-is — bank names aren't translated. */ + label: string; + /** Every one of these must be present for detectPreset() to pick this + * preset. Keep it to the columns the mapping actually needs, so a bank + * adding a trailing column doesn't break detection. */ + detect: HeaderMatcher[]; + mapping: Partial>; + dateFormat: DateFormat; + amountFormat: AmountFormat; + /** Rows whose `column` value fails `accept` are skipped before parsing — + * e.g. a "Статус" column that reads FAILED for a declined payment. */ + rowFilter?: { column: HeaderMatcher; accept: (value: string) => boolean }; +} + +/** Lower-cased, trimmed, BOM-stripped, whitespace-collapsed header text. + * Also folds the Latin "i" into the Cyrillic "і": monobank's Ukrainian header + * literally reads "Дата i час операції" with a Latin i in the middle, and + * nobody typing a matcher by hand would guess that. */ +export function normalizeHeader(header: string): string { + return header + .replace(/^\uFEFF/, "") + .trim() + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/i/g, "і"); +} + +function findHeader(headers: string[], matcher: HeaderMatcher): string { + const candidates = matcher.map(normalizeHeader); + return headers.find((header) => candidates.some((candidate) => normalizeHeader(header).startsWith(candidate))) ?? ""; +} + +export const BANK_PRESETS: readonly BankPreset[] = [ + { + // Web version → Операции → «Выгрузка операций» → CSV. Semicolon-separated, + // every field quoted, comma decimal mark, dates carry a time of day. + // "Сумма платежа" is the amount in the card's own currency (a foreign + // purchase shows the original amount in "Сумма операции"), which is what + // the account balance actually moved by. + id: "tbank", + label: "Т-Банк", + detect: [["дата операции"], ["сумма платежа"], ["описание"], ["статус"]], + mapping: { + date: ["дата операции"], + amount: ["сумма платежа"], + description: ["описание"], + category: ["категория"], + }, + dateFormat: "DD.MM.YYYY", + amountFormat: "comma-decimal", + rowFilter: { column: ["статус"], accept: (value) => value.trim().toUpperCase() === "OK" }, + }, + { + // App → card → Виписка → CSV, Ukrainian UI language. Comma-separated, + // UTF-8, dot decimal mark, empty cells written as an em dash ("—"). + // The amount header embeds the card currency, hence the prefix match. + id: "monobank", + label: "monobank", + detect: [["дата і час операції"], ["деталі операції"], ["сума в валюті картки"]], + mapping: { + date: ["дата і час операції"], + amount: ["сума в валюті картки"], + description: ["деталі операції"], + }, + dateFormat: "DD.MM.YYYY", + amountFormat: "dot-decimal", + }, + { + // next.privat24.ua → card → Виписки → CSV. Date and time come in + // separate columns, so the plain date parses as-is; the amount in the + // card's currency is what the balance moved by. + id: "privatbank", + label: "ПриватБанк", + detect: [["дата"], ["опис операції"], ["сума в валюті картки"]], + mapping: { + date: ["дата"], + amount: ["сума в валюті картки"], + description: ["опис операції"], + category: ["категорія"], + }, + dateFormat: "DD.MM.YYYY", + amountFormat: "auto", + }, +]; + +export function findPreset(id: string): BankPreset | null { + return BANK_PRESETS.find((preset) => preset.id === id) ?? null; +} + +/** The first preset whose every `detect` matcher finds a header. Order in + * BANK_PRESETS matters only if two banks ever share a header set. */ +export function detectPreset(headers: string[]): BankPreset | null { + return BANK_PRESETS.find((preset) => preset.detect.every((matcher) => findHeader(headers, matcher) !== "")) ?? null; +} + +/** Resolves a preset's matchers against the actual header row. Fields the + * preset doesn't cover fall back to `fallback` (the wizard's generic + * header guesses), so a preset only has to describe what it knows. */ +export function applyPresetMapping(preset: BankPreset, headers: string[], fallback: ImportMapping): ImportMapping { + const resolved = { ...fallback }; + (Object.keys(preset.mapping) as (keyof ImportMapping)[]).forEach((field) => { + const matcher = preset.mapping[field]; + if (matcher) resolved[field] = findHeader(headers, matcher); + }); + return resolved; +} + +/** When `preset.rowFilter` says this row is not a real transaction, returns + * the offending cell value (for the "skipped because…" message); null means + * keep the row. Also null when the preset has no filter or its column is + * missing from the header row — an absent status column is not a reason + * to drop data. */ +export function rowFilterRejection(preset: BankPreset | null, headers: string[], cells: string[]): string | null { + if (!preset?.rowFilter) return null; + const idx = headers.indexOf(findHeader(headers, preset.rowFilter.column)); + if (idx < 0) return null; + const value = cells[idx] ?? ""; + return preset.rowFilter.accept(value) ? null : value.trim(); +} diff --git a/frontend/src/lib/csv.ts b/frontend/src/lib/csv.ts index e6a66f6..54d8b6b 100644 --- a/frontend/src/lib/csv.ts +++ b/frontend/src/lib/csv.ts @@ -57,12 +57,17 @@ export function parseCsv(text: string): string[][] { 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]; +// Every pattern tolerates an optional trailing time of day ("24.10.2018 +// 17:04:25", "2024-01-05T09:30") — bank exports routinely timestamp each +// operation (T-Bank, monobank), and the wizard only stores the calendar +// date anyway. +const TIME_SUFFIX = /(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?$/.source; const DATE_PATTERNS: Record = { - "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"] }, + "YYYY-MM-DD": { regex: new RegExp(/^(\d{4})-(\d{1,2})-(\d{1,2})/.source + TIME_SUFFIX), order: ["y", "m", "d"] }, + "DD.MM.YYYY": { regex: new RegExp(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] }, + "DD/MM/YYYY": { regex: new RegExp(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] }, + "MM/DD/YYYY": { regex: new RegExp(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/.source + TIME_SUFFIX), order: ["m", "d", "y"] }, + "DD-MM-YYYY": { regex: new RegExp(/^(\d{1,2})-(\d{1,2})-(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] }, }; /** Parses `raw` per `format`, returning an ISO "YYYY-MM-DD" string, or null diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 67dfc97..39e058d 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -264,6 +264,12 @@ const ru = { "transactions.import.duplicateCheckFailed": "Не удалось проверить, есть ли уже такие транзакции на счёте — при импорте возможны дубли.", "transactions.import.duplicatesFound": "Найдено похожих на уже существующие: {{count}}. По умолчанию они не импортируются.", "transactions.import.includeDuplicates": "Всё равно импортировать их", + "transactions.import.presetLabel": "Банк / формат файла", + "transactions.import.preset.auto": "Определить автоматически", + "transactions.import.presetHint": "Готовые профили: Т-Банк, monobank, ПриватБанк — колонки и форматы подставятся сами. Выгрузки других банков импортируются через ручное сопоставление колонок.", + "transactions.import.presetDetected": "Профиль банка: {{bank}}. Колонки и форматы подставлены автоматически — проверьте и при необходимости поправьте.", + "transactions.import.presetNotDetected": "Формат банка не распознан — сопоставьте колонки вручную или выберите банк из списка.", + "transactions.import.errorRowFiltered": "операция не проведена (статус «{{value}}»)", "transactions.allTypes": "Все типы", "transactions.expense": "Расходы", "transactions.income": "Доходы", @@ -783,6 +789,12 @@ const en: Record = { "transactions.import.duplicateCheckFailed": "Couldn't check what's already on this account — importing may create duplicates.", "transactions.import.duplicatesFound": "Found {{count}} that look like duplicates of existing transactions. They're left out by default.", "transactions.import.includeDuplicates": "Import them anyway", + "transactions.import.presetLabel": "Bank / file format", + "transactions.import.preset.auto": "Detect automatically", + "transactions.import.presetHint": "Built-in profiles: T-Bank, monobank, PrivatBank — columns and formats are pre-filled. Exports from other banks go through manual column mapping.", + "transactions.import.presetDetected": "Bank profile: {{bank}}. Columns and formats were pre-filled — review and adjust if needed.", + "transactions.import.presetNotDetected": "Bank format not recognised — map the columns by hand or pick a bank from the list.", + "transactions.import.errorRowFiltered": "operation was not completed (status “{{value}}”)", "transactions.allTypes": "All types", "transactions.expense": "Expenses", "transactions.income": "Income", diff --git a/frontend/src/pages/CsvImportPage.tsx b/frontend/src/pages/CsvImportPage.tsx index 0fc82b9..44d76b6 100644 --- a/frontend/src/pages/CsvImportPage.tsx +++ b/frontend/src/pages/CsvImportPage.tsx @@ -8,6 +8,15 @@ import { useAccounts } from "@/hooks/useAccounts"; import { useCategories } from "@/hooks/useCategories"; import { useBulkCreateTransactions, useTransactionsForDuplicateCheck } from "@/hooks/useTransactions"; import { translateCategoryName } from "@/lib/categoryLabels"; +import { + BANK_PRESETS, + applyPresetMapping, + detectPreset, + findPreset, + rowFilterRejection, + type BankPreset, + type ImportMapping, +} from "@/lib/bankPresets"; import { AMOUNT_FORMATS, DATE_FORMATS, @@ -35,14 +44,12 @@ const NONE = ""; const ENCODINGS = ["utf-8", "windows-1251", "windows-1252", "koi8-r"] as const; type Encoding = (typeof ENCODINGS)[number]; -interface Mapping { - date: string; - amount: string; - description: string; - merchant: string; - notes: string; - category: string; -} +// Header names, not indexes — see lib/bankPresets.ts's ImportMapping. +type Mapping = ImportMapping; + +// "auto" lets detectPreset() pick a bank profile from the header row; a +// preset id pins one regardless of what the headers look like. +const PRESET_AUTO = "auto"; interface SkippedRow { row: number; @@ -72,6 +79,26 @@ function guessMapping(headerRow: string[]): Mapping { }; } +/** The bank profile in force for a given header row: the pinned one when + * the user picked a bank, otherwise whatever the headers match (or null — + * plain generic mapping). Pure, so both the file handler and the + * header-change effect below resolve it the same way. */ +function resolvePreset(headers: string[], presetChoice: string): BankPreset | null { + return presetChoice === PRESET_AUTO ? detectPreset(headers) : findPreset(presetChoice); +} + +/** Map-step defaults for a header row: the preset's answers where it has + * them, the generic header guesses everywhere else. */ +function resolveDefaults(headers: string[], preset: BankPreset | null): { mapping: Mapping; dateFormat: DateFormat; amountFormat: AmountFormat } { + const guessed = guessMapping(headers); + if (!preset) return { mapping: guessed, dateFormat: "YYYY-MM-DD", amountFormat: "auto" }; + return { + mapping: applyPresetMapping(preset, headers, guessed), + dateFormat: preset.dateFormat, + amountFormat: preset.amountFormat, + }; +} + export function CsvImportPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -85,6 +112,7 @@ export function CsvImportPage() { const [fileName, setFileName] = useState(""); const [fileBuffer, setFileBuffer] = useState(null); const [encoding, setEncoding] = useState("utf-8"); + const [presetChoice, setPresetChoice] = useState(PRESET_AUTO); const [mapping, setMapping] = useState({ date: "", amount: "", description: "", merchant: "", notes: "", category: "" }); const [dateFormat, setDateFormat] = useState("YYYY-MM-DD"); const [amountFormat, setAmountFormat] = useState("auto"); @@ -105,7 +133,10 @@ export function CsvImportPage() { return { headers: headerRow, dataRows: rest }; }, [fileBuffer, encoding]); - // Owns mapping initialisation, keyed on the header row's *contents*. + const activePreset = useMemo(() => resolvePreset(headers, presetChoice), [headers, presetChoice]); + + // Owns mapping initialisation, keyed on the header row's *contents* and + // the chosen bank profile. // Changing the encoding re-decodes that row, and a column picked under the // previous encoding then names a header that no longer exists: // headers.indexOf() returns -1, the dropdown renders blank while @@ -114,15 +145,25 @@ export function CsvImportPage() { // reading of a mapping whose columns are all gone. When the decoded // headers come out identical (an all-ASCII header row survives every // encoding here), the key doesn't change, this doesn't run, and manual - // column picks stay put. + // column picks stay put. Switching the bank profile re-runs it on + // purpose: the whole point of picking a bank is to replace the mapping. // JSON.stringify, not join(): joining on a separator would read // ["ab","c"] and ["a","bc"] as one and the same header row. const headersKey = JSON.stringify(headers); useEffect(() => { if (headers.length === 0) return; - setMapping(guessMapping(headers)); + const preset = resolvePreset(headers, presetChoice); + const defaults = resolveDefaults(headers, preset); + setMapping(defaults.mapping); + // Formats are only overwritten when a bank profile dictates them; with + // no profile, a date format the user picked by hand survives an + // encoding switch (the fresh-file reset lives in handleFileSelected). + if (preset) { + setDateFormat(defaults.dateFormat); + setAmountFormat(defaults.amountFormat); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [headersKey]); + }, [headersKey, presetChoice]); const categoryLookup = useMemo(() => { const map: Record<"income" | "expense", Map> = { income: new Map(), expense: new Map() }; @@ -206,6 +247,15 @@ export function CsvImportPage() { const rawNotes = notesIdx >= 0 ? (cells[notesIdx] ?? "").trim() : ""; const rawCategory = categoryIdx >= 0 ? (cells[categoryIdx] ?? "").trim() : ""; + // Bank-profile row filter first (e.g. T-Bank's "Статус" = FAILED): + // a declined payment parses perfectly well as a date + amount, and + // nothing downstream could tell it apart from a real expense. + const rejectedStatus = rowFilterRejection(activePreset, headers, cells); + if (rejectedStatus !== null) { + skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorRowFiltered", { value: rejectedStatus || "—" }) }); + return; + } + const isoDate = parseDateWithFormat(rawDate, dateFormat); if (!isoDate) { skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadDate", { value: rawDate || "—" }) }); @@ -251,7 +301,7 @@ export function CsvImportPage() { duplicateCount: duplicateRows.length, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [step, headers, dataRows, mapping, dateFormat, amountFormat, categoryLookup, accountId, existingKeys, includeDuplicates, t]); + }, [step, headers, dataRows, mapping, dateFormat, amountFormat, categoryLookup, accountId, existingKeys, includeDuplicates, activePreset, t]); async function handleFileSelected(event: React.ChangeEvent) { const file = event.target.files?.[0]; @@ -277,11 +327,16 @@ export function CsvImportPage() { return; } const [headerRow] = rows; + // Full reset for a fresh file — the header-change effect above skips + // files whose header row is byte-identical to the previous one, so + // formats from the last import can't be left over here. + const defaults = resolveDefaults(headerRow, resolvePreset(headerRow, presetChoice)); setFileName(file.name); setFileBuffer(buffer); setEncoding(detectedEncoding); - setMapping(guessMapping(headerRow)); - setAmountFormat("auto"); + setMapping(defaults.mapping); + setDateFormat(defaults.dateFormat); + setAmountFormat(defaults.amountFormat); setIncludeDuplicates(false); setStep("map"); } @@ -307,6 +362,23 @@ export function CsvImportPage() { const mappingComplete = Boolean(mapping.date && mapping.amount && mapping.description); + // Same control on the upload and map steps: picking the bank up front is + // the "I know what this file is" path, changing it on the map step is the + // "auto-detect guessed wrong" path. + const presetSelect = (id: string) => ( +
+ + +
+ ); + return (
@@ -346,6 +418,8 @@ export function CsvImportPage() {

{t("transactions.import.uploadHint")}

+ {presetSelect("upload-preset")} +

{t("transactions.import.presetHint")}