mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
feat: профили импорта CSV для Т-Банка, monobank и ПриватБанка, версия 1.1.8
This commit is contained in:
12
DOCS.md
12
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
|
export tool). All rows are validated **before** any is inserted — one bad row fails the whole
|
||||||
request with no partial import.
|
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
|
```json
|
||||||
{ "items": [ /* 1–5000 TransactionCreate objects */ ] }
|
{ "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,
|
"risky_allocation_threshold_percent": 20,
|
||||||
"idle_cash_threshold_amount": "1000.00",
|
"idle_cash_threshold_amount": "1000.00",
|
||||||
"idle_cash_threshold_days": 60,
|
"idle_cash_threshold_days": 60,
|
||||||
"app_version": "1.1.7"
|
"app_version": "1.1.8"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Aurum treats your financial life as a **system**, not a spreadsheet.
|
|||||||
## 🧩 Core Features
|
## 🧩 Core Features
|
||||||
|
|
||||||
### 💰 Cash Flow & Transactions
|
### 💰 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
|
### 📈 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.
|
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.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# it in Settings; it deliberately does NOT ride on the unauthenticated
|
# 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
|
# /api/health), and embedded in exported backups so an old file can be told
|
||||||
# apart from a current one.
|
# apart from a current one.
|
||||||
APP_VERSION = "1.1.7"
|
APP_VERSION = "1.1.8"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
119
frontend/src/lib/bankPresets.test.ts
Normal file
119
frontend/src/lib/bankPresets.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
152
frontend/src/lib/bankPresets.ts
Normal file
152
frontend/src/lib/bankPresets.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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 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];
|
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"] }> = {
|
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"] },
|
"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: /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/, 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"] },
|
||||||
"DD/MM/YYYY": { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, 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: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, order: ["m", "d", "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: /^(\d{1,2})-(\d{1,2})-(\d{4})$/, 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"] },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Parses `raw` per `format`, returning an ISO "YYYY-MM-DD" string, or null
|
/** Parses `raw` per `format`, returning an ISO "YYYY-MM-DD" string, or null
|
||||||
|
|||||||
@@ -264,6 +264,12 @@ const ru = {
|
|||||||
"transactions.import.duplicateCheckFailed": "Не удалось проверить, есть ли уже такие транзакции на счёте — при импорте возможны дубли.",
|
"transactions.import.duplicateCheckFailed": "Не удалось проверить, есть ли уже такие транзакции на счёте — при импорте возможны дубли.",
|
||||||
"transactions.import.duplicatesFound": "Найдено похожих на уже существующие: {{count}}. По умолчанию они не импортируются.",
|
"transactions.import.duplicatesFound": "Найдено похожих на уже существующие: {{count}}. По умолчанию они не импортируются.",
|
||||||
"transactions.import.includeDuplicates": "Всё равно импортировать их",
|
"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.allTypes": "Все типы",
|
||||||
"transactions.expense": "Расходы",
|
"transactions.expense": "Расходы",
|
||||||
"transactions.income": "Доходы",
|
"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.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.duplicatesFound": "Found {{count}} that look like duplicates of existing transactions. They're left out by default.",
|
||||||
"transactions.import.includeDuplicates": "Import them anyway",
|
"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.allTypes": "All types",
|
||||||
"transactions.expense": "Expenses",
|
"transactions.expense": "Expenses",
|
||||||
"transactions.income": "Income",
|
"transactions.income": "Income",
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import { useAccounts } from "@/hooks/useAccounts";
|
|||||||
import { useCategories } from "@/hooks/useCategories";
|
import { useCategories } from "@/hooks/useCategories";
|
||||||
import { useBulkCreateTransactions, useTransactionsForDuplicateCheck } from "@/hooks/useTransactions";
|
import { useBulkCreateTransactions, useTransactionsForDuplicateCheck } from "@/hooks/useTransactions";
|
||||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||||
|
import {
|
||||||
|
BANK_PRESETS,
|
||||||
|
applyPresetMapping,
|
||||||
|
detectPreset,
|
||||||
|
findPreset,
|
||||||
|
rowFilterRejection,
|
||||||
|
type BankPreset,
|
||||||
|
type ImportMapping,
|
||||||
|
} from "@/lib/bankPresets";
|
||||||
import {
|
import {
|
||||||
AMOUNT_FORMATS,
|
AMOUNT_FORMATS,
|
||||||
DATE_FORMATS,
|
DATE_FORMATS,
|
||||||
@@ -35,14 +44,12 @@ const NONE = "";
|
|||||||
const ENCODINGS = ["utf-8", "windows-1251", "windows-1252", "koi8-r"] as const;
|
const ENCODINGS = ["utf-8", "windows-1251", "windows-1252", "koi8-r"] as const;
|
||||||
type Encoding = (typeof ENCODINGS)[number];
|
type Encoding = (typeof ENCODINGS)[number];
|
||||||
|
|
||||||
interface Mapping {
|
// Header names, not indexes — see lib/bankPresets.ts's ImportMapping.
|
||||||
date: string;
|
type Mapping = ImportMapping;
|
||||||
amount: string;
|
|
||||||
description: string;
|
// "auto" lets detectPreset() pick a bank profile from the header row; a
|
||||||
merchant: string;
|
// preset id pins one regardless of what the headers look like.
|
||||||
notes: string;
|
const PRESET_AUTO = "auto";
|
||||||
category: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SkippedRow {
|
interface SkippedRow {
|
||||||
row: number;
|
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() {
|
export function CsvImportPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -85,6 +112,7 @@ export function CsvImportPage() {
|
|||||||
const [fileName, setFileName] = useState("");
|
const [fileName, setFileName] = useState("");
|
||||||
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
|
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
|
||||||
const [encoding, setEncoding] = useState<Encoding>("utf-8");
|
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 [mapping, setMapping] = useState<Mapping>({ date: "", amount: "", description: "", merchant: "", notes: "", category: "" });
|
||||||
const [dateFormat, setDateFormat] = useState<DateFormat>("YYYY-MM-DD");
|
const [dateFormat, setDateFormat] = useState<DateFormat>("YYYY-MM-DD");
|
||||||
const [amountFormat, setAmountFormat] = useState<AmountFormat>("auto");
|
const [amountFormat, setAmountFormat] = useState<AmountFormat>("auto");
|
||||||
@@ -105,7 +133,10 @@ export function CsvImportPage() {
|
|||||||
return { headers: headerRow, dataRows: rest };
|
return { headers: headerRow, dataRows: rest };
|
||||||
}, [fileBuffer, encoding]);
|
}, [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
|
// Changing the encoding re-decodes that row, and a column picked under the
|
||||||
// previous encoding then names a header that no longer exists:
|
// previous encoding then names a header that no longer exists:
|
||||||
// headers.indexOf() returns -1, the dropdown renders blank while
|
// 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
|
// reading of a mapping whose columns are all gone. When the decoded
|
||||||
// headers come out identical (an all-ASCII header row survives every
|
// headers come out identical (an all-ASCII header row survives every
|
||||||
// encoding here), the key doesn't change, this doesn't run, and manual
|
// 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
|
// JSON.stringify, not join(): joining on a separator would read
|
||||||
// ["ab","c"] and ["a","bc"] as one and the same header row.
|
// ["ab","c"] and ["a","bc"] as one and the same header row.
|
||||||
const headersKey = JSON.stringify(headers);
|
const headersKey = JSON.stringify(headers);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (headers.length === 0) return;
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [headersKey]);
|
}, [headersKey, presetChoice]);
|
||||||
|
|
||||||
const categoryLookup = useMemo(() => {
|
const categoryLookup = useMemo(() => {
|
||||||
const map: Record<"income" | "expense", Map<string, number>> = { income: new Map(), expense: new Map() };
|
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 rawNotes = notesIdx >= 0 ? (cells[notesIdx] ?? "").trim() : "";
|
||||||
const rawCategory = categoryIdx >= 0 ? (cells[categoryIdx] ?? "").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);
|
const isoDate = parseDateWithFormat(rawDate, dateFormat);
|
||||||
if (!isoDate) {
|
if (!isoDate) {
|
||||||
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadDate", { value: rawDate || "—" }) });
|
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadDate", { value: rawDate || "—" }) });
|
||||||
@@ -251,7 +301,7 @@ export function CsvImportPage() {
|
|||||||
duplicateCount: duplicateRows.length,
|
duplicateCount: duplicateRows.length,
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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>) {
|
async function handleFileSelected(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
@@ -277,11 +327,16 @@ export function CsvImportPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [headerRow] = rows;
|
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);
|
setFileName(file.name);
|
||||||
setFileBuffer(buffer);
|
setFileBuffer(buffer);
|
||||||
setEncoding(detectedEncoding);
|
setEncoding(detectedEncoding);
|
||||||
setMapping(guessMapping(headerRow));
|
setMapping(defaults.mapping);
|
||||||
setAmountFormat("auto");
|
setDateFormat(defaults.dateFormat);
|
||||||
|
setAmountFormat(defaults.amountFormat);
|
||||||
setIncludeDuplicates(false);
|
setIncludeDuplicates(false);
|
||||||
setStep("map");
|
setStep("map");
|
||||||
}
|
}
|
||||||
@@ -307,6 +362,23 @@ export function CsvImportPage() {
|
|||||||
|
|
||||||
const mappingComplete = Boolean(mapping.date && mapping.amount && mapping.description);
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -346,6 +418,8 @@ export function CsvImportPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-text-secondary">{t("transactions.import.uploadHint")}</p>
|
<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}>
|
<Button onClick={() => fileInputRef.current?.click()} disabled={!accountId}>
|
||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
{t("transactions.import.chooseFile")}
|
{t("transactions.import.chooseFile")}
|
||||||
@@ -364,6 +438,13 @@ export function CsvImportPage() {
|
|||||||
<span className="text-xs text-text-muted">{fileName}</span>
|
<span className="text-xs text-text-muted">{fileName}</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<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>
|
<div>
|
||||||
<Label htmlFor="map-encoding">{t("transactions.import.encodingLabel")}</Label>
|
<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">
|
<Select id="map-encoding" value={encoding} onChange={(event) => setEncoding(event.target.value as Encoding)} className="sm:w-56">
|
||||||
|
|||||||
Reference in New Issue
Block a user