Files
aurum/frontend/src/lib/bankPresets.ts

153 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}