mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 11:06:40 +07:00
153 lines
6.8 KiB
TypeScript
153 lines
6.8 KiB
TypeScript
// 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();
|
||
}
|