ci / lint-test (push) Successful in 41s
Deleting the Frollo importer dropped its currency handling and nothing replaced it: ColumnMapping had no currency column, so applyMapping could never produce foreign_currency_code and batchInsertCSVTransactions' support for it was unreachable from the UI. The USD 10,782 salary imported as A$10,782 — about a third under, sitting in a column of AUD figures looking entirely normal, which is the same defect the owner spotted in the first place. An optional Currency column now sets foreign_currency_code and foreign_currency_amount when the cell is a three-letter code other than AUD, and leaves amount_aud NULL rather than inventing a rate. That is the shape order ingestion already uses and what AMOUNT_UNCONVERTED looks for, so the row renders as its native figure with "no AUD rate" and the statement supplies the real number when it arrives. Caught by reading the imported row rather than the import summary: the summary said 171 inserted and was right about everything it reported.
258 lines
9.4 KiB
TypeScript
258 lines
9.4 KiB
TypeScript
export type DateFormat = "DD/MM/YYYY" | "YYYY-MM-DD" | "MM/DD/YYYY" | "M/D/YYYY";
|
|
|
|
export interface ColumnMapping {
|
|
dateCol: string;
|
|
descriptionCol: string;
|
|
amountMode: "single" | "debit_credit";
|
|
amountCol?: string;
|
|
debitCol?: string;
|
|
creditCol?: string;
|
|
merchantCol?: string;
|
|
categoryCol?: string;
|
|
/**
|
|
* Column holding the account number, when the file covers more than one.
|
|
*
|
|
* Optional and usually absent — a bank's own export is one account per file.
|
|
* An aggregator export (Frollo) is not, and without this every row would be
|
|
* measured against the same statement coverage.
|
|
*/
|
|
accountCol?: string;
|
|
/**
|
|
* Column holding the ISO currency code, when the file is multi-currency.
|
|
*
|
|
* Without it a foreign row is stored as if it were AUD — a USD 10,782 salary
|
|
* lands as A$10,782, understating it by about a third. Mapping it records the
|
|
* native figure with `foreign_currency_code` set and `amount_aud` left NULL,
|
|
* which is the same shape order ingestion uses and what `AMOUNT_UNCONVERTED`
|
|
* looks for.
|
|
*/
|
|
currencyCol?: string;
|
|
/**
|
|
* Column holding the provider's own row id, when it has one.
|
|
*
|
|
* Stored as `source_ref` so re-importing the same file inserts nothing. The
|
|
* importer has no other idempotency: `row_index` is assigned MAX+1 on every
|
|
* run, which makes `uq_transaction_identity` structurally unable to fire.
|
|
*/
|
|
sourceRefCol?: string;
|
|
}
|
|
|
|
export interface BankPreset {
|
|
bankName: string;
|
|
mapping: ColumnMapping;
|
|
dateFormat: DateFormat;
|
|
}
|
|
|
|
export interface ParsedTransaction {
|
|
date: string;
|
|
description: string;
|
|
amount: number;
|
|
transaction_type: string;
|
|
merchant_name?: string;
|
|
category?: string;
|
|
/** Raw account identifier from the file, when a column was mapped. */
|
|
account?: string;
|
|
/** Provider row id, when a column was mapped. */
|
|
source_ref?: string;
|
|
/** Set only for a non-AUD row: the native amount and its code. */
|
|
foreign_currency_amount?: number;
|
|
foreign_currency_code?: string;
|
|
}
|
|
|
|
export function parseCSVRows(text: string): string[][] {
|
|
const rows: string[][] = [];
|
|
let row: string[] = [];
|
|
let field = "";
|
|
let inQuotes = false;
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
const c = text[i];
|
|
const next = text[i + 1];
|
|
if (inQuotes) {
|
|
if (c === '"' && next === '"') { field += '"'; i++; }
|
|
else if (c === '"') { inQuotes = false; }
|
|
else { field += c; }
|
|
} else {
|
|
if (c === '"') { inQuotes = true; }
|
|
else if (c === ',') { row.push(field.trim()); field = ""; }
|
|
else if (c === '\r' && next === '\n') {
|
|
row.push(field.trim());
|
|
if (row.some((f) => f !== "")) rows.push(row);
|
|
row = []; field = ""; i++;
|
|
} else if (c === '\n' || c === '\r') {
|
|
row.push(field.trim());
|
|
if (row.some((f) => f !== "")) rows.push(row);
|
|
row = []; field = "";
|
|
} else { field += c; }
|
|
}
|
|
}
|
|
if (field || row.length > 0) {
|
|
row.push(field.trim());
|
|
if (row.some((f) => f !== "")) rows.push(row);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
export function parseDate(raw: string, format: DateFormat): string {
|
|
const s = raw.trim();
|
|
try {
|
|
if (format === "DD/MM/YYYY") {
|
|
const [d, m, y] = s.split("/");
|
|
if (!d || !m || !y || y.length !== 4) return "";
|
|
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
|
}
|
|
if (format === "YYYY-MM-DD") {
|
|
return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : "";
|
|
}
|
|
if (format === "MM/DD/YYYY" || format === "M/D/YYYY") {
|
|
const [m, d, y] = s.split("/");
|
|
if (!d || !m || !y || y.length !== 4) return "";
|
|
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
|
}
|
|
} catch { return ""; }
|
|
return "";
|
|
}
|
|
|
|
export function detectHasHeaders(rows: string[][], dateFormat: DateFormat): boolean {
|
|
if (rows.length === 0) return false;
|
|
return parseDate(rows[0][0] ?? "", dateFormat) === "";
|
|
}
|
|
|
|
export function getColumnLabels(rows: string[][], hasHeaders: boolean): string[] {
|
|
if (hasHeaders && rows.length > 0) {
|
|
return rows[0].map((h, i) => h || `Column ${i + 1}`);
|
|
}
|
|
const maxCols = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
|
return Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`);
|
|
}
|
|
|
|
export function getDataRows(rows: string[][], hasHeaders: boolean): string[][] {
|
|
return hasHeaders ? rows.slice(1) : rows;
|
|
}
|
|
|
|
export function applyMapping(
|
|
dataRows: string[][],
|
|
columnLabels: string[],
|
|
mapping: ColumnMapping,
|
|
dateFormat: DateFormat
|
|
): ParsedTransaction[] {
|
|
const idx = (name: string) => columnLabels.indexOf(name);
|
|
const dateIdx = idx(mapping.dateCol);
|
|
const descIdx = idx(mapping.descriptionCol);
|
|
const merchantIdx = mapping.merchantCol ? idx(mapping.merchantCol) : -1;
|
|
const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
|
|
const accountIdx = mapping.accountCol ? idx(mapping.accountCol) : -1;
|
|
const sourceRefIdx = mapping.sourceRefCol ? idx(mapping.sourceRefCol) : -1;
|
|
const currencyIdx = mapping.currencyCol ? idx(mapping.currencyCol) : -1;
|
|
|
|
const results: ParsedTransaction[] = [];
|
|
for (const row of dataRows) {
|
|
const date = parseDate(row[dateIdx] ?? "", dateFormat);
|
|
const description = (row[descIdx] ?? "").trim();
|
|
if (!date || !description) continue;
|
|
|
|
let amount = 0;
|
|
let transaction_type = "debit";
|
|
|
|
if (mapping.amountMode === "single" && mapping.amountCol) {
|
|
const raw = (row[idx(mapping.amountCol)] ?? "").replace(/[^\d.\-+]/g, "");
|
|
const val = parseFloat(raw);
|
|
if (isNaN(val) || val === 0) continue;
|
|
amount = Math.abs(val);
|
|
transaction_type = val < 0 ? "debit" : "credit";
|
|
} else if (mapping.amountMode === "debit_credit") {
|
|
const debitIdx = mapping.debitCol ? idx(mapping.debitCol) : -1;
|
|
const creditIdx = mapping.creditCol ? idx(mapping.creditCol) : -1;
|
|
const dVal = parseFloat((row[debitIdx] ?? "").replace(/[^\d.]/g, ""));
|
|
const cVal = parseFloat((row[creditIdx] ?? "").replace(/[^\d.]/g, ""));
|
|
if (!isNaN(dVal) && dVal > 0) { amount = dVal; transaction_type = "debit"; }
|
|
else if (!isNaN(cVal) && cVal > 0) { amount = cVal; transaction_type = "credit"; }
|
|
else continue;
|
|
}
|
|
|
|
if (amount <= 0) continue;
|
|
const tx: ParsedTransaction = { date, description, amount, transaction_type };
|
|
if (merchantIdx >= 0 && row[merchantIdx]) tx.merchant_name = row[merchantIdx].trim();
|
|
if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
|
|
if (accountIdx >= 0 && row[accountIdx]) tx.account = row[accountIdx].trim();
|
|
if (sourceRefIdx >= 0 && row[sourceRefIdx]) tx.source_ref = row[sourceRefIdx].trim();
|
|
if (currencyIdx >= 0) {
|
|
const code = (row[currencyIdx] ?? "").trim().toUpperCase();
|
|
// AUD needs no marking; anything else is native and unconverted. No rate
|
|
// is invented — the statement supplies the AUD figure when it arrives.
|
|
if (/^[A-Z]{3}$/.test(code) && code !== "AUD") {
|
|
tx.foreign_currency_code = code;
|
|
tx.foreign_currency_amount = amount;
|
|
}
|
|
}
|
|
results.push(tx);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export function saveBankPreset(preset: BankPreset): void {
|
|
try {
|
|
const existing = loadBankPresets().filter((p) => p.bankName !== preset.bankName);
|
|
localStorage.setItem("csv-presets", JSON.stringify([...existing, preset]));
|
|
} catch { /* localStorage unavailable */ }
|
|
}
|
|
|
|
export function loadBankPresets(): BankPreset[] {
|
|
try { return JSON.parse(localStorage.getItem("csv-presets") || "[]"); }
|
|
catch { return []; }
|
|
}
|
|
|
|
/** Last four digits of an account identifier, however it is written. */
|
|
export function last4(s: string): string {
|
|
const d = s.replace(/\D/g, "");
|
|
return d.length >= 4 ? d.slice(-4) : "";
|
|
}
|
|
|
|
/**
|
|
* Splits parsed rows into what to import and what the statements already have.
|
|
*
|
|
* A statement's end date is a hard watermark: everything on that account up to
|
|
* it is already in the ledger, itemised and converted. Without this the first
|
|
* aggregator import wrote 422 duplicates out of 550 rows; with it the same
|
|
* 2,607-row export offers 171.
|
|
*
|
|
* Rows are only excluded when an account column was mapped AND that account has
|
|
* statements. Anything else is imported — a row is never dropped on a guess.
|
|
*/
|
|
export function splitByCoverage(
|
|
rows: ParsedTransaction[],
|
|
coverage: { last4: string; coveredTo: string }[]
|
|
): { keep: ParsedTransaction[]; covered: ParsedTransaction[] } {
|
|
const wm = new Map(coverage.map((c) => [c.last4, c.coveredTo]));
|
|
const keep: ParsedTransaction[] = [];
|
|
const covered: ParsedTransaction[] = [];
|
|
for (const r of rows) {
|
|
const to = r.account ? wm.get(last4(r.account)) : undefined;
|
|
// Both are YYYY-MM-DD, so a string compare is a date compare.
|
|
if (to && r.date <= to) covered.push(r);
|
|
else keep.push(r);
|
|
}
|
|
return { keep, covered };
|
|
}
|
|
|
|
/**
|
|
* Rows that duplicate another row in the SAME file.
|
|
*
|
|
* Distinct from the coverage check, and not catchable by `source_ref`: a CDR
|
|
* re-consent makes the provider re-export an account's whole history under
|
|
* fresh ids while the originals survive, so the twins are identical in
|
|
* everything except the id. That put 385 duplicates in one 2,563-row export and
|
|
* doubled every salary payment. 385 rows is not something you spot by eye in a
|
|
* review table, so it is counted here.
|
|
*/
|
|
export function inFileDuplicates(rows: ParsedTransaction[]): number {
|
|
const seen = new Set<string>();
|
|
let n = 0;
|
|
for (const r of rows) {
|
|
const key = [r.date, r.description, r.amount, r.transaction_type, r.account ?? ""].join("|");
|
|
if (seen.has(key)) n += 1;
|
|
else seen.add(key);
|
|
}
|
|
return n;
|
|
}
|