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,208 @@
import { useState, useEffect } from "react";
import { X, Search, Loader2 } from "lucide-react";
import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments";
import { format } from "date-fns";
interface Account { id: string; name: string; type: string; }
interface Props {
accounts: Account[];
onClose: () => void;
onSuccess: () => void;
}
export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<AssetSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<AssetSearchResult | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const investAccounts = accounts.filter(a =>
["investment", "pension", "savings", "other"].includes(a.type)
);
const [form, setForm] = useState({
account_id: investAccounts[0]?.id ?? "",
quantity: "",
price: "",
fees: "0",
date: format(new Date(), "yyyy-MM-dd"),
});
useEffect(() => {
const t = setTimeout(async () => {
if (query.length < 1) { setResults([]); return; }
setSearching(true);
try {
const r = await searchAssets(query);
setResults(r);
} finally {
setSearching(false);
}
}, 400);
return () => clearTimeout(t);
}, [query]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selected || !form.account_id || !form.quantity || !form.price) return;
setSaving(true);
setError(null);
try {
const qty = parseFloat(form.quantity);
const price = parseFloat(form.price);
const holding = await createHolding({
account_id: form.account_id,
asset_id: selected.id,
quantity: qty,
avg_cost_basis: price,
currency: selected.currency,
});
await addInvestmentTransaction({
holding_id: holding.id,
type: "buy",
quantity: qty,
price: price,
fees: parseFloat(form.fees) || 0,
currency: selected.currency,
date: form.date,
});
onSuccess();
} catch (e: any) {
setError(e?.response?.data?.detail ?? "Failed to add holding");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-md shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border">
<h2 className="font-semibold text-lg">Add Holding</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-4">
{/* Asset search */}
<div>
<label className="text-sm font-medium block mb-1.5">Search asset *</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
value={query}
onChange={(e) => { setQuery(e.target.value); setSelected(null); }}
placeholder="e.g. AAPL, Vanguard, BTC..."
className="w-full pl-9 pr-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{searching && <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />}
</div>
{results.length > 0 && !selected && (
<div className="mt-1 border border-border rounded-lg overflow-hidden shadow-lg bg-card">
{results.map((r) => (
<button
key={r.id}
type="button"
onClick={() => { setSelected(r); setResults([]); setQuery(`${r.symbol}${r.name}`); }}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-secondary transition-colors text-left"
>
<div>
<span className="font-semibold text-sm">{r.symbol}</span>
<span className="text-muted-foreground text-sm ml-2">{r.name}</span>
</div>
<div className="text-right">
<span className="text-xs text-muted-foreground">{r.type} · {r.currency}</span>
{r.last_price && <p className="text-xs font-medium">{r.last_price}</p>}
</div>
</button>
))}
</div>
)}
{selected && (
<p className="text-xs text-success mt-1"> Selected: {selected.symbol} ({selected.name})</p>
)}
</div>
{/* Account */}
<div>
<label className="text-sm font-medium block mb-1.5">Account *</label>
<select
value={form.account_id}
onChange={(e) => setForm(f => ({ ...f, account_id: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{investAccounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
{investAccounts.length === 0 && <option value="">No investment accounts add one first</option>}
</select>
</div>
{/* Quantity / Price / Fees */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium block mb-1">Quantity *</label>
<input
type="number" min="0" step="any"
value={form.quantity}
onChange={(e) => setForm(f => ({ ...f, quantity: 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"
placeholder="10"
/>
</div>
<div>
<label className="text-xs font-medium block mb-1">Price paid *</label>
<input
type="number" min="0" step="any"
value={form.price}
onChange={(e) => setForm(f => ({ ...f, price: 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"
placeholder="150.00"
/>
</div>
<div>
<label className="text-xs font-medium block mb-1">Fees</label>
<input
type="number" min="0" step="any"
value={form.fees}
onChange={(e) => setForm(f => ({ ...f, fees: 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"
placeholder="0"
/>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Purchase date *</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm(f => ({ ...f, date: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={handleSubmit}
disabled={saving || !selected || !form.quantity || !form.price || !form.account_id}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? "Adding…" : "Add Holding"}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import { useParams, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getPriceHistory, getPortfolio } from "@/api/investments";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react";
import Plot from "react-plotly.js";
export default function AssetDetail() {
const { assetId } = useParams<{ assetId: string }>();
const { data: portfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio });
const holding = portfolio?.holdings.find(h => h.asset_id === assetId);
const { data: prices = [], isLoading } = useQuery({
queryKey: ["prices", assetId],
queryFn: () => getPriceHistory(assetId!, 365),
enabled: !!assetId,
});
const dates = prices.map(p => p.date);
const opens = prices.map(p => p.open ?? p.close);
const highs = prices.map(p => p.high ?? p.close);
const lows = prices.map(p => p.low ?? p.close);
const closes = prices.map(p => p.close);
const volumes = prices.map(p => p.volume ?? 0);
const latestPrice = closes[closes.length - 1];
const prevPrice = closes[closes.length - 2];
const change = latestPrice && prevPrice ? latestPrice - prevPrice : 0;
const changePct = prevPrice && prevPrice !== 0 ? (change / prevPrice) * 100 : 0;
const isUp = change >= 0;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link to="/investments" className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground">
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold">{holding?.symbol ?? "Asset"}</h1>
<p className="text-sm text-muted-foreground">{holding?.asset_name}</p>
</div>
</div>
{/* Price header */}
{latestPrice != null && (
<div className="flex items-end gap-4">
<p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? "GBP")}</p>
<div className={cn("flex items-center gap-1 pb-1 text-sm font-medium", isUp ? "text-success" : "text-destructive")}>
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? "GBP")} ({changePct.toFixed(2)}%)
</div>
</div>
)}
{/* Your position */}
{holding && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Shares held", value: Number(holding.quantity).toLocaleString() },
{ label: "Avg cost", value: formatCurrency(holding.avg_cost_basis, holding.currency) },
{ label: "Current value", value: holding.current_value != null ? formatCurrency(holding.current_value, holding.currency) : "—" },
{ label: "Unrealised gain", value: holding.unrealised_gain != null ? formatCurrency(holding.unrealised_gain, holding.currency) : "—", color: holding.unrealised_gain != null ? (holding.unrealised_gain >= 0 ? "text-success" : "text-destructive") : "" },
].map(({ label, value, color }) => (
<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("font-semibold tabular-nums", color)}>{value}</p>
</div>
))}
</div>
)}
{/* Candlestick chart */}
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Price History (1 Year)</p>
{isLoading ? (
<div className="h-80 animate-pulse bg-secondary/30 rounded-lg" />
) : prices.length === 0 ? (
<div className="h-80 flex items-center justify-center text-muted-foreground text-sm">No price data available</div>
) : (
<Plot
data={[
{
type: "candlestick",
x: dates,
open: opens as number[],
high: highs as number[],
low: lows as number[],
close: closes as number[],
increasing: { line: { color: "#22c55e" } },
decreasing: { line: { color: "#ef4444" } },
name: holding?.symbol ?? "Price",
},
{
type: "bar",
x: dates,
y: volumes as number[],
yaxis: "y2",
marker: { color: "rgba(99,102,241,0.3)" },
name: "Volume",
},
]}
layout={{
paper_bgcolor: "transparent",
plot_bgcolor: "transparent",
font: { color: "var(--muted-foreground)", size: 11 },
xaxis: { rangeslider: { visible: false }, gridcolor: "var(--border)", showgrid: true },
yaxis: { gridcolor: "var(--border)", showgrid: true, domain: [0.25, 1] },
yaxis2: { domain: [0, 0.2], showgrid: false },
margin: { t: 10, r: 10, b: 40, l: 60 },
showlegend: false,
dragmode: "pan",
}}
config={{ responsive: true, displayModeBar: false, scrollZoom: true }}
style={{ width: "100%", height: "360px" }}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,208 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getPortfolio, deleteHolding } from "@/api/investments";
import { getAccounts } from "@/api/accounts";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight } from "lucide-react";
import AddHoldingModal from "./AddHoldingModal";
import { Link } from "react-router-dom";
const COLORS = [
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
];
export default function PortfolioPage() {
const qc = useQueryClient();
const [showAdd, setShowAdd] = useState(false);
const { data: portfolio, isLoading } = useQuery({
queryKey: ["portfolio"],
queryFn: getPortfolio,
refetchInterval: 60_000,
});
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const deleteMutation = useMutation({
mutationFn: deleteHolding,
onSuccess: () => qc.invalidateQueries({ queryKey: ["portfolio"] }),
});
const treemapData = portfolio?.holdings
.filter((h) => (h.current_value ?? h.cost_basis_total) > 0)
.map((h, i) => ({
name: h.symbol,
size: Number(h.current_value ?? h.cost_basis_total),
fill: COLORS[i % COLORS.length],
})) ?? [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Investments</h1>
<p className="text-sm text-muted-foreground mt-1">
{portfolio ? `${portfolio.holdings.length} holding${portfolio.holdings.length !== 1 ? "s" : ""}` : ""}
</p>
</div>
<button
onClick={() => setShowAdd(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 Holding
</button>
</div>
{/* Summary cards */}
{portfolio && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Portfolio Value", value: portfolio.total_value, positive: true },
{ label: "Total Cost", value: portfolio.total_cost, positive: true },
{ label: "Unrealised Gain", value: portfolio.total_gain, positive: portfolio.total_gain >= 0 },
{ label: "Return", value: portfolio.total_gain_pct, positive: portfolio.total_gain_pct >= 0, isPercent: true },
].map(({ label, value, positive, isPercent }) => (
<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 tabular-nums", positive ? "text-success" : "text-destructive")}>
{isPercent ? `${Number(value).toFixed(2)}%` : formatCurrency(value, portfolio.currency)}
</p>
</div>
))}
</div>
)}
{/* Treemap */}
{treemapData.length > 1 && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Allocation</p>
<div className="flex gap-1 flex-wrap">
{(() => {
const total = treemapData.reduce((s, d) => s + d.size, 0);
return treemapData.map((d, i) => (
<div
key={d.name}
style={{ width: `${Math.max(d.size / total * 100, 4)}%`, backgroundColor: COLORS[i % COLORS.length] }}
className="h-16 rounded flex items-center justify-center text-white text-xs font-bold overflow-hidden"
title={`${d.name}: ${formatCurrency(d.size, "GBP")}`}
>
{d.size / total > 0.06 ? d.name : ""}
</div>
));
})()}
</div>
<div className="flex flex-wrap gap-3 mt-3">
{treemapData.map((d, i) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: COLORS[i % COLORS.length] }} />
{d.name} {formatCurrency(d.size, "GBP")}
</div>
))}
</div>
</div>
)}
{/* Holdings table */}
{isLoading ? (
<div className="space-y-2">
{[1,2,3].map(i => <div key={i} className="h-16 bg-card border border-border rounded-xl animate-pulse" />)}
</div>
) : !portfolio || portfolio.holdings.length === 0 ? (
<div className="bg-card border border-border rounded-xl py-16 text-center text-muted-foreground">
<TrendingUp className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">No holdings yet</p>
<p className="text-sm mt-1">Add your first investment holding to get started</p>
</div>
) : (
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border">
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
<th className="text-left px-4 py-3">Asset</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Quantity</th>
<th className="text-right px-4 py-3 hidden md:table-cell">Price</th>
<th className="text-right px-4 py-3">Value</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">Gain / Loss</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">24h</th>
<th className="w-16"></th>
</tr>
</thead>
<tbody>
{portfolio.holdings.map((h) => {
const isUp = (h.unrealised_gain ?? 0) >= 0;
const change24Up = (h.price_change_24h ?? 0) >= 0;
return (
<tr key={h.id} className="border-b border-border/50 hover:bg-secondary/20 transition-colors group">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary/15 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-primary">{h.symbol.slice(0,3)}</span>
</div>
<div className="min-w-0">
<p className="font-semibold truncate">{h.symbol}</p>
<p className="text-xs text-muted-foreground truncate">{h.asset_name}</p>
</div>
</div>
</td>
<td className="px-4 py-3 text-right hidden sm:table-cell tabular-nums">
{Number(h.quantity).toLocaleString()}
</td>
<td className="px-4 py-3 text-right hidden md:table-cell tabular-nums">
{h.current_price != null ? formatCurrency(h.current_price, h.currency) : "—"}
</td>
<td className="px-4 py-3 text-right font-semibold tabular-nums">
{h.current_value != null ? formatCurrency(h.current_value, h.currency) : formatCurrency(h.cost_basis_total, h.currency)}
</td>
<td className={cn("px-4 py-3 text-right hidden lg:table-cell", isUp ? "text-success" : "text-destructive")}>
{h.unrealised_gain != null ? (
<div>
<p className="tabular-nums font-medium">{isUp ? "+" : ""}{formatCurrency(h.unrealised_gain, h.currency)}</p>
<p className="text-xs">{isUp ? "+" : ""}{Number(h.unrealised_gain_pct).toFixed(2)}%</p>
</div>
) : "—"}
</td>
<td className={cn("px-4 py-3 text-right hidden lg:table-cell text-xs", change24Up ? "text-success" : "text-destructive")}>
{h.price_change_24h != null ? (
<span className="flex items-center justify-end gap-0.5">
{change24Up ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{Number(h.price_change_24h).toFixed(2)}%
</span>
) : "—"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Link
to={`/investments/${h.asset_id}`}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
>
<ChevronRight className="w-4 h-4" />
</Link>
<button
onClick={() => deleteMutation.mutate(h.id)}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showAdd && (
<AddHoldingModal
accounts={accounts}
onClose={() => setShowAdd(false)}
onSuccess={() => { qc.invalidateQueries({ queryKey: ["portfolio"] }); setShowAdd(false); }}
/>
)}
</div>
);
}