Initial commit: MyMidas personal finance tracker

Full-stack self-hosted finance app with FastAPI backend and React frontend.

Features:
- Accounts, transactions, budgets, investments with GBP base currency
- CSV import with auto-detection for 10 UK bank formats
- ML predictions: spending forecast, net worth projection, Monte Carlo
- 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger)
- Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF)
- AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log
- Encrypted nightly backups + key rotation script
- Mobile-responsive layout with bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

View file

@ -0,0 +1,448 @@
import { useState, useRef } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getAccounts, previewImport, importCsvToAccount, type CsvMapping } from "@/api/accounts";
import { getTransactions } from "@/api/transactions";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
ArrowLeft, Upload, FileText, XCircle, Loader2, CheckCircle,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, ChevronLeft, ChevronRight,
} from "lucide-react";
export default function AccountDetail() {
const { accountId } = useParams<{ accountId: string }>();
const qc = useQueryClient();
const [showImport, setShowImport] = useState(false);
const [page, setPage] = useState(1);
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const account = accounts.find(a => a.id === accountId);
const { data: txnData, isLoading: txnLoading } = useQuery({
queryKey: ["transactions", { account_id: accountId, page, page_size: 25 }],
queryFn: () => getTransactions({ account_id: accountId, page, page_size: 25 }),
enabled: !!accountId,
});
if (!account) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<p>Account not found</p>
</div>
);
}
const isLiability = ["credit_card", "loan", "mortgage"].includes(account.type);
const utilPct = account.credit_limit && account.credit_limit > 0
? Math.min(100, (Math.abs(account.current_balance) / account.credit_limit) * 100)
: null;
return (
<div className="space-y-6">
{/* Back + header */}
<div className="flex items-center gap-3">
<Link to="/accounts" className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold">{account.name}</h1>
<p className="text-sm text-muted-foreground">
{account.institution && `${account.institution} · `}
{account.type.replace(/_/g, " ")} · {account.currency}
</p>
</div>
<button
onClick={() => setShowImport(true)}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Upload className="w-4 h-4" />
Import CSV
</button>
</div>
{/* Account stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Current Balance</p>
<p className={cn("text-xl font-bold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
{formatCurrency(account.current_balance, account.currency)}
</p>
</div>
{account.credit_limit != null && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Credit Limit</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(account.credit_limit, account.currency)}</p>
</div>
)}
{account.interest_rate != null && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Interest Rate</p>
<p className="text-xl font-bold">{Number(account.interest_rate).toFixed(2)}% p.a.</p>
</div>
)}
{txnData && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Transactions</p>
<p className="text-xl font-bold tabular-nums">{txnData.total}</p>
</div>
)}
</div>
{/* Credit utilisation */}
{utilPct !== null && (
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex justify-between text-sm mb-2">
<span className="font-medium">Credit Utilisation</span>
<span className={cn("font-semibold", utilPct > 80 ? "text-destructive" : utilPct > 50 ? "text-yellow-500" : "text-success")}>
{utilPct.toFixed(0)}%
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", utilPct > 80 ? "bg-destructive" : utilPct > 50 ? "bg-yellow-500" : "bg-success")}
style={{ width: `${utilPct}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(Math.abs(account.current_balance), account.currency)} used of {formatCurrency(account.credit_limit!, account.currency)}
</p>
</div>
)}
{account.notes && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Notes</p>
<p className="text-sm">{account.notes}</p>
</div>
)}
{/* Transactions */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<p className="font-semibold">Transactions</p>
{txnData && txnData.pages > 1 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Page {page} of {txnData.pages}</span>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="p-1 rounded hover:bg-secondary disabled:opacity-40">
<ChevronLeft className="w-4 h-4" />
</button>
<button onClick={() => setPage(p => Math.min(txnData.pages, p + 1))} disabled={page === txnData.pages} className="p-1 rounded hover:bg-secondary disabled:opacity-40">
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
{txnLoading ? (
<div className="space-y-px">
{[1, 2, 3, 4, 5].map(i => <div key={i} className="h-14 bg-secondary/20 animate-pulse" />)}
</div>
) : !txnData?.items.length ? (
<div className="py-16 text-center text-muted-foreground text-sm">
No transactions yet.{" "}
<button onClick={() => setShowImport(true)} className="text-primary hover:underline">Import from CSV</button>
</div>
) : (
<div>
{txnData.items.map(txn => (
<div key={txn.id} className="flex items-center gap-3 px-5 py-3 border-b border-border/50 hover:bg-secondary/20 transition-colors">
<div className={cn("p-1.5 rounded-lg shrink-0",
txn.type === "income" ? "bg-success/10" :
txn.type === "transfer" ? "bg-primary/10" : "bg-destructive/10"
)}>
{txn.type === "income"
? <ArrowUpCircle className="w-3.5 h-3.5 text-success" />
: txn.type === "transfer"
? <ArrowLeftRight className="w-3.5 h-3.5 text-primary" />
: <ArrowDownCircle className="w-3.5 h-3.5 text-destructive" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{txn.description}</p>
<p className="text-xs text-muted-foreground">{format(new Date(txn.date), "dd MMM yyyy")}</p>
</div>
<p className={cn("text-sm font-semibold tabular-nums shrink-0",
txn.type === "income" ? "text-success" :
txn.type === "expense" ? "text-destructive" : "text-muted-foreground"
)}>
{Number(txn.amount) > 0 ? "+" : ""}{formatCurrency(txn.amount, txn.currency)}
</p>
</div>
))}
</div>
)}
</div>
{showImport && account && (
<ImportModal
accountId={account.id}
accountName={account.name}
onClose={() => setShowImport(false)}
onSuccess={() => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["accounts"] });
qc.invalidateQueries({ queryKey: ["net-worth"] });
}}
/>
)}
</div>
);
}
// ─── Import Modal ─────────────────────────────────────────────────────────────
type ImportStep = "upload" | "preview" | "done";
function ImportModal({
accountId, accountName, onClose, onSuccess,
}: {
accountId: string;
accountName: string;
onClose: () => void;
onSuccess: () => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<ImportStep>("upload");
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<Awaited<ReturnType<typeof previewImport>> | null>(null);
const [mapping, setMapping] = useState<CsvMapping | null>(null);
const [result, setResult] = useState<{ imported: number; skipped: number } | null>(null);
const [detecting, setDetecting] = useState(false);
const [detectError, setDetectError] = useState<string | null>(null);
const importMutation = useMutation({
mutationFn: () => importCsvToAccount(accountId, file!, mapping!),
onSuccess: (data) => {
setResult(data);
setStep("done");
onSuccess();
},
});
async function handleFileSelect(f: File) {
setFile(f);
setDetecting(true);
setDetectError(null);
try {
const p = await previewImport(accountId, f);
setPreview(p);
setMapping(p.mapping);
setStep("preview");
} catch (e: any) {
setDetectError(e?.response?.data?.detail ?? "Failed to read file");
} finally {
setDetecting(false);
}
}
function onDrop(e: React.DragEvent) {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f?.name.toLowerCase().endsWith(".csv")) handleFileSelect(f);
}
const isSplit = mapping ? (!!mapping.debit && !!mapping.credit) : false;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-card border border-border rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-border">
<div>
<h2 className="font-semibold text-lg">Import CSV</h2>
<p className="text-xs text-muted-foreground mt-0.5">into {accountName}</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<XCircle className="w-5 h-5" />
</button>
</div>
<div className="p-5 space-y-5">
{/* Step: upload */}
{step === "upload" && (
<>
<div
onDrop={onDrop}
onDragOver={e => e.preventDefault()}
onClick={() => fileRef.current?.click()}
className="border-2 border-dashed border-border rounded-xl p-10 text-center cursor-pointer hover:border-primary/50 transition-colors"
>
{detecting ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-sm">Detecting format</p>
</div>
) : (
<div className="text-muted-foreground">
<Upload className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm font-medium">Drop your bank CSV here</p>
<p className="text-xs mt-1 opacity-60">Supports Monzo, Starling, Revolut, Barclays, Lloyds, NatWest, HSBC, Santander, Nationwide</p>
</div>
)}
<input
ref={fileRef}
type="file"
accept=".csv"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileSelect(f); }}
/>
</div>
{detectError && <p className="text-destructive text-sm text-center">{detectError}</p>}
</>
)}
{/* Step: preview + mapping */}
{step === "preview" && preview && mapping && (
<>
{/* Detected format badge */}
<div className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm",
preview.detected_format ? "bg-success/10 text-success" : "bg-yellow-500/10 text-yellow-600"
)}>
{preview.detected_format ? (
<><CheckCircle className="w-4 h-4 shrink-0" /> Detected: <strong>{preview.detected_format}</strong></>
) : (
<><FileText className="w-4 h-4 shrink-0" /> Unknown format please verify column mapping below</>
)}
<span className="ml-auto text-xs opacity-70">{preview.total_rows} rows</span>
</div>
{/* Column mapping */}
<div>
<p className="text-sm font-semibold mb-3">Column Mapping</p>
<div className="grid grid-cols-2 gap-3">
<ColSelect label="Date column *" value={mapping.date} headers={preview.headers}
onChange={v => setMapping(m => m ? { ...m, date: v } : m)} />
<ColSelect label="Description column *" value={mapping.description} headers={preview.headers}
onChange={v => setMapping(m => m ? { ...m, description: v } : m)} />
{isSplit ? (
<>
<ColSelect label="Debit column (money out)" value={mapping.debit ?? ""} headers={["", ...preview.headers]}
onChange={v => setMapping(m => m ? { ...m, debit: v || null } : m)} />
<ColSelect label="Credit column (money in)" value={mapping.credit ?? ""} headers={["", ...preview.headers]}
onChange={v => setMapping(m => m ? { ...m, credit: v || null } : m)} />
</>
) : (
<ColSelect label="Amount column *" value={mapping.amount ?? ""} headers={preview.headers}
onChange={v => setMapping(m => m ? { ...m, amount: v || null } : m)} />
)}
<div className="flex items-end gap-2">
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input
type="checkbox"
checked={isSplit}
onChange={e => {
if (e.target.checked) {
setMapping(m => m ? { ...m, amount: null, debit: preview.headers[0] ?? "", credit: preview.headers[1] ?? "" } : m);
} else {
setMapping(m => m ? { ...m, debit: null, credit: null, amount: preview.headers[0] ?? "" } : m);
}
}}
className="rounded"
/>
Separate debit/credit columns
</label>
</div>
</div>
</div>
{/* Preview table */}
<div>
<p className="text-sm font-semibold mb-2">Preview (first {preview.preview.length} rows)</p>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-xs">
<thead className="bg-secondary/40">
<tr>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Date</th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Description</th>
<th className="text-right px-3 py-2 text-muted-foreground font-medium">Amount</th>
</tr>
</thead>
<tbody>
{preview.preview.map((row, i) => (
<tr key={i} className="border-t border-border/50">
<td className="px-3 py-2 text-muted-foreground">{row.date_raw}</td>
<td className="px-3 py-2 truncate max-w-xs">{row.description_raw}</td>
<td className={cn("px-3 py-2 text-right tabular-nums font-medium",
row.amount_raw == null ? "text-muted-foreground" :
row.amount_raw >= 0 ? "text-success" : "text-destructive"
)}>
{row.amount_raw != null ? formatCurrency(row.amount_raw, "GBP") : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{importMutation.isError && (
<p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">
{(importMutation.error as any)?.response?.data?.detail ?? "Import failed"}
</p>
)}
<div className="flex gap-3">
<button onClick={() => { setStep("upload"); setFile(null); setPreview(null); }}
className="flex-1 border border-border rounded-lg py-2.5 text-sm hover:bg-secondary transition-colors">
Back
</button>
<button
onClick={() => importMutation.mutate()}
disabled={importMutation.isPending || !mapping.date || !mapping.description || (!isSplit && !mapping.amount) || (isSplit && (!mapping.debit || !mapping.credit))}
className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{importMutation.isPending ? <><Loader2 className="w-4 h-4 animate-spin" /> Importing</> : `Import ${preview.total_rows} rows`}
</button>
</div>
</>
)}
{/* Step: done */}
{step === "done" && result && (
<div className="text-center py-8 space-y-4">
<CheckCircle className="w-14 h-14 text-success mx-auto" />
<div>
<p className="text-xl font-bold">{result.imported} transaction{result.imported !== 1 ? "s" : ""} imported</p>
{result.skipped > 0 && (
<p className="text-sm text-muted-foreground mt-1">{result.skipped} duplicate{result.skipped !== 1 ? "s" : ""} skipped</p>
)}
</div>
<div className="flex gap-3 justify-center">
<button onClick={() => { setStep("upload"); setFile(null); setPreview(null); setResult(null); }}
className="border border-border rounded-lg px-5 py-2 text-sm hover:bg-secondary transition-colors">
Import another
</button>
<button onClick={onClose}
className="bg-primary text-primary-foreground rounded-lg px-5 py-2 text-sm font-medium hover:bg-primary/90 transition-colors">
Done
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function ColSelect({ label, value, headers, onChange }: {
label: string; value: string; headers: string[]; onChange: (v: string) => void;
}) {
return (
<div>
<label className="text-xs text-muted-foreground block mb-1">{label}</label>
<select
value={value}
onChange={e => onChange(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{headers.map(h => <option key={h} value={h}>{h || "— none —"}</option>)}
</select>
</div>
);
}