mirror of
https://github.com/Zproger/Aurum.git
synced 2026-11-05 15:27:23 +07:00
feat: профили импорта CSV для Т-Банка, monobank и ПриватБанка, версия 1.1.8
This commit is contained in:
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 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user