From 18fe6b514150757676137722e981084da66a4520 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:56:59 -0400 Subject: [PATCH] Add CSV import UI, recurring transactions, and security fixes - FileUpload: full rewrite with column mapping, split debit/credit columns, negate toggle, duplicate detection, OFX/QFX support, and live preview - ManualEntry: add recurring transaction checkbox with weekly/biweekly/monthly/yearly options - DataInput: pass existingTransactions to FileUpload for duplicate detection - Settings: use session token for delete-account; replace reset-code password flow with proper current+new password form - page.tsx: fix TypeScript build error by mapping date strings to Date objects after setup wizard completes - SetupWizard: fix hasSplit boolean type error Co-Authored-By: Claude Sonnet 4.6 --- src/app/page.tsx | 6 +- src/components/DataInput.tsx | 4 +- src/components/FileUpload.tsx | 500 +++++++++++++++++---------------- src/components/ManualEntry.tsx | 24 ++ src/components/Settings.tsx | 121 ++++---- src/components/SetupWizard.tsx | 2 +- 6 files changed, 346 insertions(+), 311 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 1fbdb3f..7412d65 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -332,9 +332,13 @@ export default function BudgetDashboard() { const base = normalizeBalanceByType(a.type, a.startingBalance ?? a.balance); return { ...a, startingBalance: base, balance: base }; }); + const txnsWithDates = (userData.transactions ?? []).map((t: any) => ({ + ...t, + date: typeof t.date === 'string' ? new Date(t.date) : t.date, + })); setAccounts(accsWithBase); setEnvelopes(userData.envelopes ?? envs); - setTransactions(userData.transactions ?? []); + setTransactions(txnsWithDates); setGoals(userData.goals ?? []); setTransactionRules(userData.transactionRules ?? []); } else { diff --git a/src/components/DataInput.tsx b/src/components/DataInput.tsx index 4fa2a32..fd7231e 100644 --- a/src/components/DataInput.tsx +++ b/src/components/DataInput.tsx @@ -55,6 +55,8 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts, tr date: Date; envelopeId?: string; accountId: string; + isRecurring?: boolean; + recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly'; }) => { const newTransaction = { id: crypto.randomUUID(), @@ -103,7 +105,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts, tr transactions={transactions} /> ) : ( - + )} {/* Help Text */} diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index 232334f..cf5ae2e 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; interface FileUploadProps { onTransactionsParsed: (transactions: ParsedTransaction[]) => void; accounts: { id: string; name: string }[]; + existingTransactions?: { description: string; date: Date | string; amount: number }[]; } export interface ParsedTransaction { @@ -15,265 +16,282 @@ export interface ParsedTransaction { accountId?: string; } -export default function FileUpload({ onTransactionsParsed, accounts }: FileUploadProps) { - const [selectedFile, setSelectedFile] = useState(null); - const [fileType, setFileType] = useState('csv'); - const [isProcessing, setIsProcessing] = useState(false); - const [selectedAccountId, setSelectedAccountId] = useState(''); +interface CsvRow { [key: string]: string } - const supportedFormats = [ - { value: 'csv', label: 'CSV (Comma Separated Values)', extensions: '.csv' }, - { value: 'ofx', label: 'OFX (Open Financial Exchange)', extensions: '.ofx' }, - { value: 'qfx', label: 'QFX (Quicken Financial Exchange)', extensions: '.qfx' }, - { value: 'qbo', label: 'QBO (QuickBooks Online)', extensions: '.qbo' }, - { value: 'pdf', label: 'PDF Statement', extensions: '.pdf' }, - ]; +export default function FileUpload({ onTransactionsParsed, accounts, existingTransactions = [] }: FileUploadProps) { + const [selectedAccountId, setSelectedAccountId] = useState(accounts?.[0]?.id || ''); + const [csvHeaders, setCsvHeaders] = useState([]); + const [csvRows, setCsvRows] = useState([]); + const [fileName, setFileName] = useState(''); + const [dateCol, setDateCol] = useState(''); + const [descCol, setDescCol] = useState(''); + const [amtCol, setAmtCol] = useState(''); + const [amtInCol, setAmtInCol] = useState(''); + const [amtOutCol, setAmtOutCol] = useState(''); + const [splitAmounts, setSplitAmounts] = useState(false); + const [negateAmt, setNegateAmt] = useState(false); + const [skipDuplicates, setSkipDuplicates] = useState(true); + const [fileType, setFileType] = useState<'csv' | 'ofx'>('csv'); - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - setSelectedFile(file); + const parseRow = (line: string): string[] => { + const result: string[] = []; + let cur = ''; + let inQuote = false; + for (const ch of line) { + if (ch === '"') { inQuote = !inQuote; } + else if (ch === ',' && !inQuote) { result.push(cur.trim()); cur = ''; } + else { cur += ch; } } + result.push(cur.trim()); + return result; }; - const parseCSV = (content: string): ParsedTransaction[] => { - const lines = content.split('\n'); - const transactions: ParsedTransaction[] = []; + const handleFile = (file: File) => { + const ext = file.name.split('.').pop()?.toLowerCase() ?? ''; + const isOfx = ['ofx', 'qfx', 'qbo'].includes(ext); + setFileType(isOfx ? 'ofx' : 'csv'); + setFileName(file.name); - // Try to detect header row and column positions - const firstLine = lines[0]?.trim(); - if (!firstLine) return transactions; - - const headers = firstLine.split(',').map(h => h.trim().toLowerCase()); - const dateIndex = headers.findIndex(h => h.includes('date') || h.includes('dt')); - const amountIndex = headers.findIndex(h => h.includes('amount') || h.includes('amt') || h.includes('value')); - const descIndex = headers.findIndex(h => h.includes('description') || h.includes('desc') || h.includes('memo') || h.includes('reference')); - const categoryIndex = headers.findIndex(h => h.includes('category') || h.includes('cat')); - - // Skip header row - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - - const columns = line.split(','); - if (columns.length >= 3) { - const dateStr = columns[dateIndex]?.trim(); - const amountStr = columns[amountIndex]?.trim(); - const descStr = columns[descIndex]?.trim(); - const categoryStr = columns[categoryIndex]?.trim(); - - if (!dateStr || !amountStr || !descStr) continue; - - // Try multiple date formats - let date: Date | null = null; - const dateFormats = [ - // MM/DD/YYYY - () => { - const parts = dateStr.split('/'); - if (parts.length === 3) { - return new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1])); - } - return null; - }, - // YYYY-MM-DD - () => new Date(dateStr + 'T00:00:00'), - // MM-DD-YYYY - () => { - const parts = dateStr.split('-'); - if (parts.length === 3) { - return new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1])); - } - return null; - }, - // DD/MM/YYYY - () => { - const parts = dateStr.split('/'); - if (parts.length === 3) { - return new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0])); - } - return null; - } - ]; - - for (const formatFn of dateFormats) { - date = formatFn(); - if (date && !isNaN(date.getTime())) break; - } - - const amount = parseFloat(amountStr.replace(/[$,]/g, '')); - - if (date && !isNaN(date.getTime()) && !isNaN(amount)) { - transactions.push({ - date, - amount, - description: descStr, - category: categoryStr || undefined, - accountId: selectedAccountId || undefined, - }); - } + const reader = new FileReader(); + reader.onload = e => { + const text = e.target?.result as string; + if (isOfx) { + const txns = parseOFX(text); + onTransactionsParsed(txns.map(t => ({ ...t, accountId: selectedAccountId || undefined }))); + return; } - } - - return transactions; - }; - - const parseOFX = (content: string): ParsedTransaction[] => { - // Basic OFX parsing - this would need to be more robust for production - const transactions: ParsedTransaction[] = []; - const transactionRegex = /[\s\S]*?<\/STMTTRN>/g; - const matches = content.match(transactionRegex); - - if (matches) { - matches.forEach(match => { - const dateMatch = match.match(/(.*?)<\/DTPOSTED>/); - const amountMatch = match.match(/(.*?)<\/TRNAMT>/); - const descMatch = match.match(/(.*?)<\/MEMO>/) || match.match(/(.*?)<\/NAME>/); - - if (dateMatch && amountMatch && descMatch) { - const dateStr = dateMatch[1].substring(0, 8); // YYYYMMDD format - const date = new Date( - parseInt(dateStr.substring(0, 4)), - parseInt(dateStr.substring(4, 6)) - 1, - parseInt(dateStr.substring(6, 8)) - ); - const amount = parseFloat(amountMatch[1]); - const description = descMatch[1]; - - transactions.push({ - date, - amount, - description, - }); - } + // CSV flow + const lines = text.split(/\r?\n/).filter(l => l.trim()); + if (lines.length < 2) return; + const headers = parseRow(lines[0]); + const rows: CsvRow[] = lines.slice(1).map(line => { + const vals = parseRow(line); + const row: CsvRow = {}; + headers.forEach((h, i) => { row[h] = vals[i] ?? ''; }); + return row; }); - } + setCsvHeaders(headers); + setCsvRows(rows); - return transactions; + // Auto-detect columns + const find = (...candidates: string[]) => + headers.find(h => candidates.some(c => h.toLowerCase().includes(c))) ?? ''; + const d = find('date', 'posted', 'transaction date'); + const desc = find('description', 'desc', 'memo', 'payee', 'name'); + const amt = find('amount', 'amt', 'transaction amount'); + const inC = find('credit', 'deposit', 'money in'); + const outC = find('debit', 'withdrawal', 'money out'); + const hasSplit = !amt && !!(inC || outC); + setDateCol(d); setDescCol(desc); setAmtCol(amt); + setAmtInCol(inC); setAmtOutCol(outC); setSplitAmounts(hasSplit); + }; + reader.readAsText(file); }; - const handleUpload = async () => { - if (!selectedFile) return; - - setIsProcessing(true); - - try { - const content = await selectedFile.text(); - let transactions: ParsedTransaction[] = []; - - switch (fileType) { - case 'csv': - transactions = parseCSV(content); - break; - case 'ofx': - case 'qfx': - transactions = parseOFX(content); - break; - case 'qbo': - // QBO parsing would be similar to OFX - transactions = parseOFX(content); - break; - case 'pdf': - // PDF parsing would require a PDF parsing library - alert('PDF parsing is not yet implemented. Please use CSV or OFX format.'); - setIsProcessing(false); - return; - default: - alert('Unsupported file format'); - setIsProcessing(false); - return; - } - - onTransactionsParsed(transactions); - setSelectedFile(null); - // Reset file input - const fileInput = document.getElementById('file-upload') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - - } catch (error) { - console.error('Error parsing file:', error); - alert('Error parsing file. Please check the format and try again.'); - } finally { - setIsProcessing(false); - } + const parseOFX = (text: string): ParsedTransaction[] => { + const txns: ParsedTransaction[] = []; + const matches = text.match(/[\s\S]*?<\/STMTTRN>/gi) ?? []; + matches.forEach(m => { + const dateM = m.match(/(.*?)(.*?)(.*?)(.*?) parseFloat((s ?? '').replace(/[$,\s]/g, '').replace(/\((.+)\)/, '-$1')) || 0; + + const buildPreview = (): { date: string; desc: string; amt: number }[] => + csvRows.slice(0, 5).map(row => { + const amt = splitAmounts + ? parseAmt(row[amtInCol] ?? '') - Math.abs(parseAmt(row[amtOutCol] ?? '')) + : negateAmt ? -parseAmt(row[amtCol]) : parseAmt(row[amtCol]); + return { date: row[dateCol] ?? '', desc: row[descCol] ?? '', amt }; + }); + + const isDuplicate = (desc: string, dateStr: string, amt: number) => { + if (!skipDuplicates) return false; + const d = new Date(dateStr).toDateString(); + return existingTransactions.some(t => + t.description === desc && + Math.abs(t.amount - amt) < 0.01 && + new Date(t.date).toDateString() === d + ); + }; + + const handleImport = () => { + if (!dateCol || !descCol || (!splitAmounts && !amtCol) || (splitAmounts && !amtInCol && !amtOutCol)) { + alert('Please map Date, Description, and Amount columns.'); + return; + } + let skipped = 0; + const txns: ParsedTransaction[] = []; + csvRows.forEach(row => { + const amt = splitAmounts + ? parseAmt(row[amtInCol] ?? '') - Math.abs(parseAmt(row[amtOutCol] ?? '')) + : negateAmt ? -parseAmt(row[amtCol]) : parseAmt(row[amtCol]); + if (amt === 0) return; + const rawDate = row[dateCol] ?? ''; + let date: Date; + try { date = new Date(rawDate); if (isNaN(date.getTime())) return; } + catch { return; } + const desc = row[descCol] ?? 'Imported transaction'; + if (isDuplicate(desc, rawDate, amt)) { skipped++; return; } + txns.push({ date, amount: amt, description: desc, accountId: selectedAccountId || undefined }); + }); + onTransactionsParsed(txns); + if (skipped > 0) alert(`Imported ${txns.length} transactions. Skipped ${skipped} duplicates.`); + setCsvHeaders([]); setCsvRows([]); setFileName(''); + }; + + const colOpts = ['', ...csvHeaders]; + const preview = csvHeaders.length && dateCol && descCol ? buildPreview() : []; + return ( -
-

Import Bank Transactions

+
+

Import Bank Transactions

-
-
- - -
+ {/* Account selector */} +
+ + +
-
-
- -
-

Supported formats:

-
    -
  • CSV: Date, Amount, Description columns
  • -
  • OFX/QFX: Standard bank export formats
  • -
  • QBO: QuickBooks Online format
  • -
  • PDF: Coming soon
  • -
-
+ )}
); -} \ No newline at end of file +} diff --git a/src/components/ManualEntry.tsx b/src/components/ManualEntry.tsx index e4cb52d..1715cec 100644 --- a/src/components/ManualEntry.tsx +++ b/src/components/ManualEntry.tsx @@ -22,6 +22,8 @@ interface ManualEntryProps { date: Date; envelopeId?: string; accountId: string; + isRecurring?: boolean; + recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly'; }) => void; envelopes: Envelope[]; accounts: { id: string; name: string }[]; @@ -35,6 +37,8 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t const [selectedEnvelope, setSelectedEnvelope] = useState(''); const [selectedAccount, setSelectedAccount] = useState(accounts?.[0]?.id || ''); const [isExpense, setIsExpense] = useState(true); + const [isRecurring, setIsRecurring] = useState(false); + const [recurringFrequency, setRecurringFrequency] = useState<'weekly' | 'biweekly' | 'monthly' | 'yearly'>('monthly'); // Update selected account when accounts change useEffect(() => { @@ -88,6 +92,8 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t date: new Date(date), envelopeId: selectedEnvelope || undefined, accountId: selectedAccount, + isRecurring: isRecurring || undefined, + recurringFrequency: isRecurring ? recurringFrequency : undefined, }); // Reset form @@ -96,6 +102,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t setDate(new Date().toISOString().split('T')[0]); setSelectedEnvelope(''); setSelectedAccount(accounts?.[0]?.id || ''); + setIsRecurring(false); }; if (!accounts || accounts.length === 0) { @@ -237,6 +244,23 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
+ {/* Recurring toggle */} +
+ + {isRecurring && ( + + )} +
+
diff --git a/src/components/SetupWizard.tsx b/src/components/SetupWizard.tsx index 9064c8d..c33decc 100644 --- a/src/components/SetupWizard.tsx +++ b/src/components/SetupWizard.tsx @@ -461,7 +461,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP const amtCol = find('amount', 'amt', 'debit/credit', 'transaction amount'); const inCol = find('credit', 'deposit', 'money in', 'additions'); const outCol = find('debit', 'withdrawal', 'money out', 'subtractions'); - const hasSplit = !amtCol && (inCol || outCol); + const hasSplit = !amtCol && !!(inCol || outCol); setCsvMapping(m => ({ ...m,