feat: профили импорта CSV для Т-Банка, monobank и ПриватБанка, версия 1.1.8

This commit is contained in:
ZProger
2026-09-13 06:32:36 +03:00
parent c867710f38
commit 848ff21665
9 changed files with 403 additions and 24 deletions

12
DOCS.md
View File

@@ -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"
}
```

View File

@@ -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.

View File

@@ -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):

View File

@@ -1,7 +1,7 @@
{
"name": "aurum-frontend",
"private": true,
"version": "1.1.7",
"version": "1.1.8",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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();
});
});

View File

@@ -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<Record<keyof ImportMapping, HeaderMatcher>>;
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();
}

View File

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

View File

@@ -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<keyof typeof ru, string> = {
"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",

View File

@@ -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<ArrayBuffer | null>(null);
const [encoding, setEncoding] = useState<Encoding>("utf-8");
const [presetChoice, setPresetChoice] = useState<string>(PRESET_AUTO);
const [mapping, setMapping] = useState<Mapping>({ date: "", amount: "", description: "", merchant: "", notes: "", category: "" });
const [dateFormat, setDateFormat] = useState<DateFormat>("YYYY-MM-DD");
const [amountFormat, setAmountFormat] = useState<AmountFormat>("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<string, number>> = { 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<HTMLInputElement>) {
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) => (
<div>
<Label htmlFor={id}>{t("transactions.import.presetLabel")}</Label>
<Select id={id} value={presetChoice} onChange={(event) => setPresetChoice(event.target.value)} className="sm:w-72">
<option value={PRESET_AUTO}>{t("transactions.import.preset.auto")}</option>
{BANK_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</Select>
</div>
);
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
@@ -346,6 +418,8 @@ export function CsvImportPage() {
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-text-secondary">{t("transactions.import.uploadHint")}</p>
{presetSelect("upload-preset")}
<p className="text-xs text-text-muted">{t("transactions.import.presetHint")}</p>
<Button onClick={() => fileInputRef.current?.click()} disabled={!accountId}>
<Upload size={16} />
{t("transactions.import.chooseFile")}
@@ -364,6 +438,13 @@ export function CsvImportPage() {
<span className="text-xs text-text-muted">{fileName}</span>
</CardHeader>
<CardContent className="space-y-3">
{presetSelect("map-preset")}
<p className={`text-xs ${activePreset ? "text-success" : "text-text-muted"}`}>
{activePreset
? t("transactions.import.presetDetected", { bank: activePreset.label })
: t("transactions.import.presetNotDetected")}
</p>
<div>
<Label htmlFor="map-encoding">{t("transactions.import.encodingLabel")}</Label>
<Select id="map-encoding" value={encoding} onChange={(event) => setEncoding(event.target.value as Encoding)} className="sm:w-56">