diff --git a/prisma/migrations/0010_csv_import_reconcile/migration.sql b/prisma/migrations/0010_csv_import_reconcile/migration.sql new file mode 100644 index 0000000..7bbcebd --- /dev/null +++ b/prisma/migrations/0010_csv_import_reconcile/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE transactions ADD COLUMN reconciled_with_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL; +CREATE INDEX idx_transactions_reconciled ON transactions(reconciled_with_id) WHERE reconciled_with_id IS NOT NULL; diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts new file mode 100644 index 0000000..d5942c9 --- /dev/null +++ b/src/app/api/import/csv/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { ensureTag, batchInsertCSVTransactions } from "@/lib/queries"; + +export async function POST(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const body = await req.json() as { + bank_name: string; + transactions: { + date: string; + description: string; + amount: number; + transaction_type: string; + merchant_name?: string; + foreign_currency_amount?: number; + foreign_currency_code?: string; + category?: string; + }[]; + }; + + if (!Array.isArray(body.transactions) || body.transactions.length === 0) { + return NextResponse.json({ error: "No transactions provided" }, { status: 400 }); + } + + const tagId = await ensureTag("csv-import", "#8b5cf6"); + const inserted = await batchInsertCSVTransactions(user.id, body.transactions, tagId); + + return NextResponse.json({ inserted }, { status: 201 }); +} diff --git a/src/app/api/reconcile/pending/route.ts b/src/app/api/reconcile/pending/route.ts new file mode 100644 index 0000000..6d74073 --- /dev/null +++ b/src/app/api/reconcile/pending/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getPendingReconciliations } from "@/lib/queries"; + +export async function GET(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const data = await getPendingReconciliations(user.id); + return NextResponse.json(data); +} diff --git a/src/app/api/transactions/reconcile/route.ts b/src/app/api/transactions/reconcile/route.ts new file mode 100644 index 0000000..529c35c --- /dev/null +++ b/src/app/api/transactions/reconcile/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma, queryRaw } from "@/lib/db"; + +export async function POST(req: NextRequest) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const body = await req.json() as { + matches: { manual_id: number; statement_tx_id: number }[]; + }; + + if (!Array.isArray(body.matches) || body.matches.length === 0) { + return NextResponse.json({ error: "No matches provided" }, { status: 400 }); + } + + // Verify all manual_ids belong to this user + const manualIds = body.matches.map((m) => m.manual_id); + const owned = await queryRaw<{ id: number }>( + `SELECT id FROM transactions WHERE id = ANY($1::int[]) AND statement_id IS NULL AND owner_id = $2`, + [manualIds, user.id] + ); + if (owned.length !== manualIds.length) { + return NextResponse.json({ error: "One or more transactions not found" }, { status: 404 }); + } + + let reconciled = 0; + + for (const { manual_id, statement_tx_id } of body.matches) { + await prisma.$transaction(async (tx) => { + // Copy overrides: manual → statement tx + const override = await tx.transaction_overrides.findUnique({ + where: { transaction_id: manual_id }, + }); + if (override) { + await tx.transaction_overrides.upsert({ + where: { transaction_id: statement_tx_id }, + update: { + category_override: override.category_override, + merchant_normalized: override.merchant_normalized, + notes: override.notes, + my_share_percent: override.my_share_percent, + updated_at: new Date(), + }, + create: { + transaction_id: statement_tx_id, + category_override: override.category_override, + merchant_normalized: override.merchant_normalized, + notes: override.notes, + my_share_percent: override.my_share_percent, + }, + }); + } + + // Copy tags: manual → statement tx + const tags = await tx.transaction_tags.findMany({ where: { transaction_id: manual_id } }); + if (tags.length) { + await tx.transaction_tags.createMany({ + data: tags.map((t) => ({ transaction_id: statement_tx_id, tag_id: t.tag_id })), + skipDuplicates: true, + }); + } + + // Copy splits: manual → statement tx + const splits = await tx.transaction_splits.findMany({ where: { transaction_id: manual_id } }); + if (splits.length) { + await tx.transaction_splits.createMany({ + data: splits.map((s) => ({ + transaction_id: statement_tx_id, + participant_id: s.participant_id, + share_percent: s.share_percent, + })), + skipDuplicates: true, + }); + } + + // Mark manual tx as reconciled (link to statement tx) + await tx.$executeRawUnsafe( + `UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, + statement_tx_id, + manual_id + ); + }); + reconciled++; + } + + return NextResponse.json({ reconciled }); +} diff --git a/src/app/reconcile/page.tsx b/src/app/reconcile/page.tsx new file mode 100644 index 0000000..4613573 --- /dev/null +++ b/src/app/reconcile/page.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useState } from "react"; +import { usePendingReconciliations, useReconcile } from "@/lib/hooks"; +import type { ManualTxWithMatches, PotentialMatch } from "@/lib/queries"; + +function formatDate(d: string | Date) { + return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" }); +} + +function formatAmt(amount: number, type: string) { + const f = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(amount); + return ["debit", "fee", "interest"].includes(type) ? f : `+${f}`; +} + +const TYPE_COLORS: Record = { + debit: "bg-red-900/30 text-red-400", + credit: "bg-green-900/30 text-green-400", + payment: "bg-blue-900/30 text-blue-400", + refund: "bg-emerald-900/30 text-emerald-400", + fee: "bg-yellow-900/30 text-yellow-400", + interest: "bg-orange-900/30 text-orange-400", + transfer: "bg-zinc-800 text-zinc-400", +}; + +// Selections: manual_id → statement_tx_id or null (skip) +type Selections = Record; + +export default function ReconcilePage() { + const { data: pending = [], isLoading, refetch } = usePendingReconciliations(); + const reconcile = useReconcile(); + + const [selections, setSelections] = useState({}); + const [error, setError] = useState(""); + const [done, setDone] = useState<{ reconciled: number } | null>(null); + + const withMatches = pending.filter((tx) => tx.matches.length > 0); + const noMatches = pending.filter((tx) => tx.matches.length === 0); + + // Statement tx IDs already chosen in another row this session + const usedStatementIds = new Set(Object.values(selections).filter((v): v is number => v !== null)); + + function selectMatch(manualId: number, matchId: number) { + setSelections((prev) => { + // If this matchId was previously selected for a different manual tx, clear that + const updated: Selections = { ...prev }; + for (const [k, v] of Object.entries(updated)) { + if (v === matchId && Number(k) !== manualId) delete updated[Number(k)]; + } + updated[manualId] = matchId; + return updated; + }); + } + + function skipManual(manualId: number) { + setSelections((prev) => ({ ...prev, [manualId]: null })); + } + + const confirmedMatches = Object.entries(selections) + .filter(([, v]) => v !== null) + .map(([k, v]) => ({ manual_id: Number(k), statement_tx_id: v as number })); + + async function handleApply() { + if (!confirmedMatches.length) return; + setError(""); + try { + const result = await reconcile.mutateAsync(confirmedMatches); + setDone(result); + setSelections({}); + refetch(); + } catch (e) { + setError(e instanceof Error ? e.message : "Reconcile failed"); + } + } + + if (isLoading) { + return
Loading...
; + } + + if (done) { + return ( +
+
+

✓ {done.reconciled} transaction{done.reconciled !== 1 ? "s" : ""} reconciled

+

Overrides, tags, and splits copied to statement transactions.

+ +
+
+ ); + } + + if (pending.length === 0) { + return ( +
+

Reconcile

+

No unreconciled manual transactions. Import a CSV to get started.

+
+ ); + } + + return ( +
+
+
+

Reconcile

+

+ {pending.length} manual transaction{pending.length !== 1 ? "s" : ""} ·{" "} + {withMatches.length} with potential matches +

+
+ {confirmedMatches.length > 0 && ( + + )} +
+ + {error &&

{error}

} + + {/* Transactions with matches */} + {withMatches.map((tx) => ( + selectMatch(tx.id, matchId)} + onSkip={() => skipManual(tx.id)} + onClear={() => setSelections((prev) => { const n = { ...prev }; delete n[tx.id]; return n; })} + /> + ))} + + {/* Transactions with no matches */} + {noMatches.length > 0 && ( +
+
+

No statement matches found ({noMatches.length})

+

These may not have hit a statement yet, or the statement hasn't been imported.

+
+
+ {noMatches.map((tx) => ( +
+ {formatDate(tx.transaction_date)} + {tx.effective_merchant || tx.description} + + {formatAmt(tx.amount, tx.transaction_type)} + +
+ {(tx.tags as { id: number; name: string; color: string }[]).map((tag) => ( + + ))} +
+
+ ))} +
+
+ )} +
+ ); +} + +function ReconcileRow({ + tx, selection, usedStatementIds, onSelect, onSkip, onClear, +}: { + tx: ManualTxWithMatches; + selection: number | null | undefined; + usedStatementIds: Set; + onSelect: (matchId: number) => void; + onSkip: () => void; + onClear: () => void; +}) { + const isSkipped = selection === null; + const selectedMatchId = typeof selection === "number" ? selection : null; + const tags = tx.tags as { id: number; name: string; color: string }[]; + + return ( +
+ {/* Manual tx header */} +
+
+
+ {formatDate(tx.transaction_date)} + {tx.effective_merchant || tx.description} + {tx.effective_merchant && ( + {tx.description} + )} +
+ {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag.name} + + ))} +
+ )} +
+ + {formatAmt(tx.amount, tx.transaction_type)} + + + {tx.transaction_type} + +
+ + {/* Matches */} +
+ {tx.matches.map((match) => { + const isSelected = selectedMatchId === match.id; + const isUsedElsewhere = !isSelected && usedStatementIds.has(match.id); + return ( + onSelect(match.id)} + /> + ); + })} + + {/* Skip / status row */} +
+ {selectedMatchId ? ( + ✓ Match selected + ) : isSkipped ? ( + Skipped — will stay as manual transaction + ) : ( + Select a match above, or skip + )} +
+ {(selectedMatchId || isSkipped) && ( + + )} + {!isSkipped && ( + + )} +
+
+
+
+ ); +} + +function MatchRow({ + match, isSelected, isDisabled, onSelect, +}: { + match: PotentialMatch; + isSelected: boolean; + isDisabled: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx index 56a7bb2..2414747 100644 --- a/src/app/transactions/page.tsx +++ b/src/app/transactions/page.tsx @@ -8,6 +8,7 @@ import { SplitModal } from "@/components/split-modal"; import { TagPicker } from "@/components/tag-picker"; import { AddTransactionModal } from "@/components/add-transaction-modal"; import { EditTransactionModal } from "@/components/edit-transaction-modal"; +import { CsvImportModal } from "@/components/csv-import-modal"; import type { TransactionRow } from "@/lib/queries"; function formatDate(d: string) { @@ -507,6 +508,7 @@ function TransactionsContent() { const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null); const [addModal, setAddModal] = useState<{ prefill?: Parameters[0]["prefill"]; title?: string } | null>(null); const [editModal, setEditModal] = useState(null); + const [showImportModal, setShowImportModal] = useState(false); const [paymentModal, setPaymentModal] = useState(null); const [rulePrompt, setRulePrompt] = useState<{ tx: { id: number; effective_merchant: string; description: string; bank_name: string }; @@ -562,12 +564,20 @@ function TransactionsContent() {

Transactions

- +
+ + +
{/* Statement context banner */} @@ -933,6 +943,8 @@ function TransactionsContent() { /> )} + {showImportModal && setShowImportModal(false)} />} + {addModal && ( void; + options: string[]; required?: boolean; +}) { + return ( +
+ + +
+ ); +} + +export function CsvImportModal({ onClose }: { onClose: () => void }) { + const importCSV = useImportCSV(); + const fileRef = useRef(null); + + const [step, setStep] = useState("upload"); + const [rawRows, setRawRows] = useState([]); + const [hasHeaders, setHasHeaders] = useState(false); + const [columnLabels, setColumnLabels] = useState([]); + const [dataRows, setDataRows] = useState([]); + const [bankName, setBankName] = useState(""); + const [dateFormat, setDateFormat] = useState("DD/MM/YYYY"); + const [mapping, setMapping] = useState({ + dateCol: "", descriptionCol: "", amountMode: "single", amountCol: "", + }); + const [savePreset, setSavePreset] = useState(false); + const [editedRows, setEditedRows] = useState([]); + const [error, setError] = useState(""); + const [presets, setPresets] = useState([]); + const [insertedCount, setInsertedCount] = useState(0); + + useEffect(() => { setPresets(loadBankPresets()); }, []); + + function handleFile(file: File) { + setError(""); + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + const rows = parseCSVRows(text); + if (rows.length === 0) { setError("No data found in file"); return; } + setRawRows(rows); + const headers = detectHasHeaders(rows, dateFormat); + setHasHeaders(headers); + const labels = getColumnLabels(rows, headers); + setColumnLabels(labels); + setDataRows(getDataRows(rows, headers)); + // auto-set first columns as defaults + setMapping((m) => ({ + ...m, + dateCol: labels[0] ?? "", + descriptionCol: labels[1] ?? "", + amountCol: labels[2] ?? "", + })); + setStep("map"); + }; + reader.readAsText(file); + } + + function applyPreset(preset: BankPreset) { + setBankName(preset.bankName); + setDateFormat(preset.dateFormat); + setMapping(preset.mapping); + } + + function refreshColumns() { + if (!rawRows.length) return; + const headers = detectHasHeaders(rawRows, dateFormat); + setHasHeaders(headers); + const labels = getColumnLabels(rawRows, headers); + setColumnLabels(labels); + setDataRows(getDataRows(rawRows, headers)); + } + + function handleNext() { + setError(""); + if (!bankName.trim()) { setError("Bank name is required"); return; } + if (!mapping.dateCol) { setError("Date column is required"); return; } + if (!mapping.descriptionCol) { setError("Description column is required"); return; } + if (mapping.amountMode === "single" && !mapping.amountCol) { setError("Amount column is required"); return; } + if (mapping.amountMode === "debit_credit" && !mapping.debitCol && !mapping.creditCol) { + setError("At least one of debit/credit columns is required"); return; + } + const parsed = applyMapping(dataRows, columnLabels, mapping, dateFormat); + if (parsed.length === 0) { setError("No valid transactions could be parsed — check your column mapping and date format"); return; } + if (savePreset) { + saveBankPreset({ bankName: bankName.trim(), mapping, dateFormat }); + } + setEditedRows(parsed); + setStep("review"); + } + + async function handleImport() { + setError(""); + const valid = editedRows.filter((r) => r.date && r.amount > 0 && r.description); + if (!valid.length) { setError("No valid rows to import"); return; } + try { + const result = await importCSV.mutateAsync({ bank_name: bankName, transactions: valid }); + setInsertedCount(result.inserted); + setStep("done"); + } catch (e) { + setError(e instanceof Error ? e.message : "Import failed"); + } + } + + function updateRow(i: number, patch: Partial) { + setEditedRows((rows) => rows.map((r, idx) => idx === i ? { ...r, ...patch } : r)); + } + + function deleteRow(i: number) { + setEditedRows((rows) => rows.filter((_, idx) => idx !== i)); + } + + const modalWidth = step === "review" ? "max-w-5xl" : step === "map" ? "max-w-xl" : "max-w-md"; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

Import CSV

+
+ {(["upload", "map", "review", "done"] as Step[]).map((s, i) => ( + + {i + 1}. {s.charAt(0).toUpperCase() + s.slice(1)} + + ))} +
+
+ +
+ + {/* Body */} +
+ + {/* Step 1: Upload */} + {step === "upload" && ( +
+ {presets.length > 0 && ( +
+ + +
+ )} +
+ { const f = e.target.files?.[0]; if (f) handleFile(f); }} + /> + +
+ {error &&

{error}

} +
+ )} + + {/* Step 2: Map */} + {step === "map" && ( +
+ {/* Raw preview */} +
+

First 3 rows from file:

+
+ + + + {columnLabels.map((h) => ( + + ))} + + + + {dataRows.slice(0, 3).map((row, i) => ( + + {columnLabels.map((_, ci) => ( + + ))} + + ))} + +
{h}
{row[ci] ?? ""}
+
+
+ +
+
+ + setBankName(e.target.value)} + placeholder="e.g. Westpac" + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + /> +
+
+ + +
+
+ +
+ setMapping((m) => ({ ...m, dateCol: v }))} options={columnLabels} required /> + setMapping((m) => ({ ...m, descriptionCol: v }))} options={columnLabels} required /> +
+ +
+ +
+ + +
+ {mapping.amountMode === "single" ? ( + setMapping((m) => ({ ...m, amountCol: v }))} options={columnLabels} required /> + ) : ( +
+ setMapping((m) => ({ ...m, debitCol: v }))} options={columnLabels} /> + setMapping((m) => ({ ...m, creditCol: v }))} options={columnLabels} /> +
+ )} +
+ +
+ setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} /> + setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} /> +
+ + + + {error &&

{error}

} +
+ )} + + {/* Step 3: Review */} + {step === "review" && ( +
+

+ {editedRows.length} transactions parsed. Edit or remove rows before importing. +

+
+ + + + {["Date", "Description", "Amount", "Type", "Merchant", "Category", ""].map((h) => ( + + ))} + + + + {editedRows.map((row, i) => ( + + + + + + + + + + ))} + +
{h}
+ updateRow(i, { date: e.target.value })} + className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" /> + + updateRow(i, { description: e.target.value })} + className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-48 focus:outline-none focus:border-indigo-500" /> + + updateRow(i, { amount: parseFloat(e.target.value) || 0 })} + className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-20 focus:outline-none focus:border-indigo-500" /> + + + + updateRow(i, { merchant_name: e.target.value || undefined })} + className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" /> + + + + +
+
+ {error &&

{error}

} +
+ )} + + {/* Step 4: Done */} + {step === "done" && ( +
+
+

Imported {insertedCount} transactions

+

Tagged with csv-import

+ +
+ )} +
+ + {/* Footer */} + {step !== "done" && ( +
+ {step === "map" && ( + + )} + {step === "review" && ( + + )} +
+ + {step === "map" && ( + + )} + {step === "review" && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 18e5fec..098100c 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -13,6 +13,7 @@ const NAV_ITEMS = [ { href: "/merchants", label: "Merchants", icon: "store" }, { href: "/tags", label: "Tags", icon: "tag" }, { href: "/rules", label: "Rules", icon: "settings" }, + { href: "/reconcile", label: "Reconcile", icon: "git-merge" }, ]; const ICONS: Record = { @@ -52,6 +53,11 @@ const ICONS: Record = { ), + "git-merge": ( + + + + ), store: ( diff --git a/src/lib/csv-parser.ts b/src/lib/csv-parser.ts new file mode 100644 index 0000000..8029951 --- /dev/null +++ b/src/lib/csv-parser.ts @@ -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 []; } +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index ee0e087..b1e166b 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -727,6 +727,66 @@ export interface MerchantTxnRow { statement_id: number; } +// --- CSV Import & Reconcile --- + +export function useImportCSV() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: { + bank_name: string; + transactions: { + date: string; description: string; amount: number; transaction_type: string; + merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string; category?: string; + }[]; + }) => { + const res = await fetch("/api/import/csv", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error((await res.json()).error || "Import failed"); + return res.json() as Promise<{ inserted: number }>; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + qc.invalidateQueries({ queryKey: ["tags"] }); + qc.invalidateQueries({ queryKey: ["reconcile-pending"] }); + }, + }); +} + +import type { ManualTxWithMatches } from "./queries"; + +export function usePendingReconciliations() { + return useQuery({ + queryKey: ["reconcile-pending"], + queryFn: async () => { + const res = await fetch("/api/reconcile/pending"); + if (!res.ok) throw new Error("Failed to fetch"); + return res.json(); + }, + }); +} + +export function useReconcile() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (matches: { manual_id: number; statement_tx_id: number }[]) => { + const res = await fetch("/api/transactions/reconcile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ matches }), + }); + if (!res.ok) throw new Error((await res.json()).error || "Reconcile failed"); + return res.json() as Promise<{ reconciled: number }>; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + qc.invalidateQueries({ queryKey: ["reconcile-pending"] }); + }, + }); +} + export function useMerchantTransactions(merchant: string | null) { return useQuery<{ transactions: MerchantTxnRow[] }>({ queryKey: ["analytics", "merchant-txns", merchant], diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8e5894d..dae815e 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -85,7 +85,10 @@ interface TransactionFilters { } export async function getTransactions(ownerId: number, filters: TransactionFilters) { - const conditions: string[] = [`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`]; + const conditions: string[] = [ + `(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`, + `NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`, + ]; const params: unknown[] = [ownerId]; let paramIdx = 2; @@ -344,6 +347,182 @@ export interface SharedTransactionRow extends TransactionRow { splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[]; } +export async function ensureTag(name: string, color: string): Promise { + const rows = await queryRaw<{ id: number }>( + `INSERT INTO tags (name, color) VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id`, + [name, color] + ); + return rows[0].id; +} + +export async function batchInsertCSVTransactions( + ownerId: number, + rows: { + date: string; + description: string; + amount: number; + transaction_type: string; + merchant_name?: string; + foreign_currency_amount?: number; + foreign_currency_code?: string; + category?: string; + }[], + tagId: number +): Promise { + if (rows.length === 0) return 0; + + const baseRows = await queryRaw<{ base: number }>( + `SELECT COALESCE(MAX(row_index), -1) as base FROM transactions WHERE owner_id = $1 AND statement_id IS NULL`, + [ownerId] + ); + const base = Number(baseRows[0].base); + + const valueClauses: string[] = []; + const params: unknown[] = [ownerId]; + let p = 2; + rows.forEach((r, i) => { + valueClauses.push(`(NULL, $1, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, ${base + 1 + i})`); + params.push(r.date, r.description, r.amount, r.transaction_type, r.merchant_name ?? null, r.foreign_currency_amount ?? null, r.foreign_currency_code ?? null); + }); + + const txIds = await queryRaw<{ id: number }>( + `INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_name, foreign_currency_amount, foreign_currency_code, row_index) + VALUES ${valueClauses.join(", ")} + RETURNING id`, + params + ); + + if (txIds.length > 0) { + const tagValueClauses = txIds.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`); + const tagParams: unknown[] = txIds.flatMap((r) => [r.id, tagId]); + await queryRaw( + `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${tagValueClauses.join(", ")} ON CONFLICT DO NOTHING`, + tagParams + ); + } + + return txIds.length; +} + +export interface PotentialMatch { + id: number; + transaction_date: string; + description: string; + amount: number; + transaction_type: string; + effective_merchant: string; + effective_category: string; + bank_name: string; + billing_end_date: string | null; +} + +export interface ManualTxWithMatches extends TransactionRow { + matches: PotentialMatch[]; +} + +export async function getPendingReconciliations(ownerId: number): Promise { + // Fetch all unreconciled manual transactions + const raw = await queryRaw( + `SELECT t.*, + o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent, + COALESCE(o.category_override, t.category) as effective_category, + COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, + 'Manual' as bank_name, + t.owner_id, + p.name as owner_name, + txn_tags.tags, + txn_splits.splits + FROM transactions t + LEFT JOIN transaction_overrides o ON o.transaction_id = t.id + LEFT JOIN participants p ON p.id = t.owner_id + LEFT JOIN LATERAL ( + SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags + FROM transaction_tags tt JOIN tags tg ON tg.id = tt.tag_id + WHERE tt.transaction_id = t.id + ) txn_tags ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits + FROM transaction_splits ts JOIN participants sp ON sp.id = ts.participant_id + WHERE ts.transaction_id = t.id + ) txn_splits ON true + WHERE t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL + ORDER BY t.transaction_date DESC, t.row_index ASC`, + [ownerId] + ); + + const manualTxs = raw.map((r) => ({ + ...r, + tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []), + splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []), + })) as TransactionRow[]; + + if (manualTxs.length === 0) return []; + + // Fetch all potential matches in one query using window function + const matchRows = await queryRaw( + `SELECT manual_id, id, transaction_date, description, amount, transaction_type, + effective_merchant, effective_category, bank_name, billing_end_date + FROM ( + SELECT + m.id AS manual_id, + t.id, + t.transaction_date, + t.description, + t.amount, + t.transaction_type, + COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, '') AS effective_merchant, + COALESCE(o.category_override, t.category, '') AS effective_category, + s.bank_name, + s.billing_end_date, + ROW_NUMBER() OVER ( + PARTITION BY m.id + ORDER BY ABS(t.amount - m.amount), ABS(t.transaction_date - m.transaction_date) + ) AS rn + FROM transactions m + JOIN transactions t ON t.statement_id IS NOT NULL + AND t.transaction_date BETWEEN m.transaction_date - 3 AND m.transaction_date + 3 + AND t.amount BETWEEN m.amount * 0.99 AND m.amount * 1.01 + JOIN statements s ON s.id = t.statement_id + LEFT JOIN transaction_overrides o ON o.transaction_id = t.id + WHERE m.statement_id IS NULL + AND m.owner_id = $1 + AND m.reconciled_with_id IS NULL + AND COALESCE(t.owner_id, s.owner_id) = $1 + AND NOT EXISTS ( + SELECT 1 FROM transactions mt WHERE mt.reconciled_with_id = t.id + ) + ) sq + WHERE rn <= 5 + ORDER BY manual_id, rn`, + [ownerId] + ); + + // Group matches by manual_id + const matchesByManualId = new Map(); + for (const row of matchRows) { + const list = matchesByManualId.get(row.manual_id) ?? []; + list.push({ + id: row.id, + transaction_date: row.transaction_date, + description: row.description, + amount: row.amount, + transaction_type: row.transaction_type, + effective_merchant: row.effective_merchant, + effective_category: row.effective_category, + bank_name: row.bank_name, + billing_end_date: row.billing_end_date, + }); + matchesByManualId.set(row.manual_id, list); + } + + return manualTxs.map((tx) => ({ + ...tx, + matches: matchesByManualId.get(tx.id) ?? [], + })); +} + export async function getTags() { return queryRaw(` SELECT tg.id, tg.name, tg.color,