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(); 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; }