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,295 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getAccounts, createAccount, updateAccount, deleteAccount, getNetWorth, type Account } from "@/api/accounts";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import {
Plus, Trash2, Pencil, TrendingUp, Wallet,
CreditCard, PiggyBank, Building2, Coins, Bitcoin, Landmark, ShieldCheck, Sprout
} from "lucide-react";
import AccountFormModal from "./AccountFormModal";
const TYPE_ICONS: Record<string, React.ElementType> = {
checking: Wallet,
savings: PiggyBank,
cash_isa: Sprout,
stocks_shares_isa: TrendingUp,
credit_card: CreditCard,
investment: TrendingUp,
cash: Coins,
crypto_wallet: Bitcoin,
loan: Building2,
mortgage: Landmark,
pension: ShieldCheck,
other: Wallet,
};
const TYPE_LABELS: Record<string, string> = {
checking: "Checking",
savings: "Savings",
cash_isa: "Cash ISA",
stocks_shares_isa: "S&S ISA",
credit_card: "Credit Card",
investment: "Investment",
cash: "Cash",
crypto_wallet: "Crypto",
loan: "Loan",
mortgage: "Mortgage",
pension: "Pension",
other: "Other",
};
const LIABILITY_TYPES = new Set(["credit_card", "loan", "mortgage"]);
export default function AccountList() {
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [editing, setEditing] = useState<Account | null>(null);
const { data: accounts = [], isLoading } = useQuery({
queryKey: ["accounts"],
queryFn: getAccounts,
});
const { data: nw } = useQuery({
queryKey: ["net-worth"],
queryFn: getNetWorth,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["accounts"] });
qc.invalidateQueries({ queryKey: ["net-worth"] });
};
const deleteMutation = useMutation({
mutationFn: deleteAccount,
onSuccess: invalidate,
});
const createMutation = useMutation({
mutationFn: createAccount,
onSuccess: () => { invalidate(); setShowCreate(false); },
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof updateAccount>[1] }) =>
updateAccount(id, data),
onSuccess: () => { invalidate(); setEditing(null); },
});
const assets = accounts.filter(a => !LIABILITY_TYPES.has(a.type) && a.is_active);
const liabilities = accounts.filter(a => LIABILITY_TYPES.has(a.type) && a.is_active);
const inactive = accounts.filter(a => !a.is_active);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Accounts</h1>
<p className="text-sm text-muted-foreground mt-1">Manage your financial accounts</p>
</div>
<button
onClick={() => setShowCreate(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"
>
<Plus className="w-4 h-4" />
Add Account
</button>
</div>
{nw && (
<div className="grid grid-cols-3 gap-4">
{[
{ label: "Total Assets", value: nw.total_assets, positive: true },
{ label: "Total Liabilities", value: nw.total_liabilities, positive: nw.total_liabilities === 0 },
{ label: "Net Worth", value: nw.net_worth, positive: nw.net_worth >= 0 },
].map(({ label, value, positive }) => (
<div key={label} className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={cn("text-xl font-bold", positive ? "text-success" : "text-destructive")}>
{formatCurrency(value, nw.base_currency)}
</p>
</div>
))}
</div>
)}
{isLoading && (
<div className="space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-20 bg-card border border-border rounded-xl animate-pulse" />
))}
</div>
)}
{assets.length > 0 && (
<AccountGroup
title="Assets"
accounts={assets}
onEdit={setEditing}
onDelete={id => deleteMutation.mutate(id)}
/>
)}
{liabilities.length > 0 && (
<AccountGroup
title="Liabilities"
accounts={liabilities}
onEdit={setEditing}
onDelete={id => deleteMutation.mutate(id)}
/>
)}
{inactive.length > 0 && (
<AccountGroup
title="Inactive"
accounts={inactive}
onEdit={setEditing}
onDelete={id => deleteMutation.mutate(id)}
muted
/>
)}
{accounts.length === 0 && !isLoading && (
<div className="text-center py-16 text-muted-foreground">
<Wallet className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="font-medium">No accounts yet</p>
<p className="text-sm mt-1">Add your first account to get started</p>
</div>
)}
{showCreate && (
<AccountFormModal
onClose={() => setShowCreate(false)}
onSubmit={data => createMutation.mutate(data)}
isLoading={createMutation.isPending}
/>
)}
{editing && (
<AccountFormModal
account={editing}
onClose={() => setEditing(null)}
onSubmit={data => updateMutation.mutate({ id: editing.id, data })}
isLoading={updateMutation.isPending}
/>
)}
</div>
);
}
function AccountGroup({
title,
accounts,
onEdit,
onDelete,
muted = false,
}: {
title: string;
accounts: Account[];
onEdit: (a: Account) => void;
onDelete: (id: string) => void;
muted?: boolean;
}) {
return (
<div>
<h2 className={cn("text-sm font-semibold uppercase tracking-wider mb-3", muted ? "text-muted-foreground" : "text-foreground")}>
{title}
</h2>
<div className="space-y-2">
{accounts.map(account => {
const Icon = TYPE_ICONS[account.type] || Wallet;
const isLiability = LIABILITY_TYPES.has(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
key={account.id}
className="bg-card border border-border rounded-xl p-4 hover:border-primary/30 transition-colors group"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg shrink-0" style={{ backgroundColor: account.color + "20" }}>
<Icon className="w-5 h-5" style={{ color: account.color }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link to={`/accounts/${account.id}`} className="font-medium truncate hover:text-primary transition-colors">
{account.name}
</Link>
<span className="text-xs text-muted-foreground bg-secondary px-1.5 py-0.5 rounded shrink-0">
{TYPE_LABELS[account.type] || account.type}
</span>
{!account.include_in_net_worth && (
<span className="text-xs text-muted-foreground italic">excluded from net worth</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{account.institution && (
<p className="text-xs text-muted-foreground">{account.institution}</p>
)}
{account.interest_rate != null && (
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{Number(account.interest_rate).toFixed(2)}%</span> p.a.
</p>
)}
{account.notes && (
<p className="text-xs text-muted-foreground truncate max-w-xs">{account.notes}</p>
)}
</div>
</div>
<div className="text-right shrink-0">
<p className={cn("font-semibold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
{formatCurrency(account.current_balance, account.currency)}
</p>
{account.credit_limit != null && (
<p className="text-xs text-muted-foreground">
limit {formatCurrency(account.credit_limit, account.currency)}
</p>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={() => onEdit(account)}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
title="Edit account"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(account.id)}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Delete account"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Credit utilisation bar */}
{utilPct !== null && (
<div className="mt-3">
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>Credit used</span>
<span>{utilPct.toFixed(0)}%</span>
</div>
<div className="h-1.5 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>
</div>
)}
</div>
);
})}
</div>
</div>
);
}