feat: CSV import and batch reconciliation UI
- Add reconciled_with_id column to transactions (links manual → statement tx) - CSV import wizard: 4-step modal (upload → map columns → review → done) - Handles any bank format via column mapping with localStorage presets - Single signed or separate debit/credit column modes - Editable preview table before committing - Auto-tags all imported rows with 'csv-import' - Batch reconcile page: shows all unreconciled manual transactions with potential statement matches (date ±3 days, amount ±1%) pre-fetched - Select matches across multiple rows, apply all at once - Copies overrides/tags/splits from manual → statement tx atomically - Manual tx marked reconciled (linked), hidden from main transactions view - Transactions with no matches shown separately - Import CSV button on transactions page - Reconcile nav item in sidebar
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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();
|
||||
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 []; }
|
||||
}
|
||||
Reference in New Issue
Block a user