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,164 @@
import { useState } from "react";
import { X } from "lucide-react";
import { BudgetCreate } from "@/api/budgets";
import { format } from "date-fns";
interface Category {
id: string;
name: string;
type: string;
}
interface Props {
categories: Category[];
onClose: () => void;
onSubmit: (data: BudgetCreate) => void;
isLoading: boolean;
}
export default function BudgetFormModal({ categories, onClose, onSubmit, isLoading }: Props) {
const today = format(new Date(), "yyyy-MM-dd");
const [form, setForm] = useState<BudgetCreate>({
category_id: "",
name: "",
amount: 0,
currency: "GBP",
period: "monthly",
start_date: today,
end_date: null,
rollover: false,
alert_threshold: 80,
});
const expenseCategories = categories.filter((c) => c.type === "expense" || c.type === "system");
function set<K extends keyof BudgetCreate>(key: K, value: BudgetCreate[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.category_id || !form.name || !form.amount) return;
onSubmit(form);
}
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">New Budget</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="text-sm font-medium block mb-1.5">Budget name *</label>
<input
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. Monthly Groceries"
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"
required
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Category *</label>
<select
value={form.category_id}
onChange={(e) => set("category_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"
required
>
<option value="">Select category...</option>
{expenseCategories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Amount *</label>
<input
type="number"
min="0.01"
step="0.01"
value={form.amount || ""}
onChange={(e) => set("amount", parseFloat(e.target.value) || 0)}
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"
required
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Period</label>
<select
value={form.period}
onChange={(e) => set("period", e.target.value as BudgetCreate["period"])}
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"
>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Start date *</label>
<input
type="date"
value={form.start_date}
onChange={(e) => set("start_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"
required
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Alert at (%)</label>
<input
type="number"
min="0"
max="100"
value={form.alert_threshold}
onChange={(e) => set("alert_threshold", parseFloat(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>
</div>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={form.rollover}
onChange={(e) => set("rollover", e.target.checked)}
className="rounded border-input"
/>
Roll over unused budget to next period
</label>
<div className="flex gap-3 pt-2">
<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
type="submit"
disabled={isLoading}
className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{isLoading ? "Creating..." : "Create Budget"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,197 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBudgetSummary, createBudget, deleteBudget } from "@/api/budgets";
import { getCategories } from "@/api/transactions";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { Plus, Trash2, AlertTriangle, CheckCircle } from "lucide-react";
import BudgetFormModal from "./BudgetFormModal";
function RadialGauge({ percent, size = 80 }: { percent: number; size?: number }) {
const r = size / 2 - 8;
const circumference = 2 * Math.PI * r;
const clamped = Math.min(percent, 100);
const offset = circumference - (clamped / 100) * circumference;
const color = percent >= 100 ? "#ef4444" : percent >= 80 ? "#f97316" : "#22c55e";
return (
<svg width={size} height={size} className="shrink-0">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={6}
className="text-secondary"
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={6}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{ transition: "stroke-dashoffset 0.4s ease" }}
/>
<text
x={size / 2}
y={size / 2 + 4}
textAnchor="middle"
fontSize={12}
fontWeight="600"
fill={color}
>
{Math.round(percent)}%
</text>
</svg>
);
}
export default function BudgetPage() {
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const { data: summary = [], isLoading } = useQuery({
queryKey: ["budget-summary"],
queryFn: getBudgetSummary,
refetchInterval: 60_000,
});
const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: getCategories });
const createMutation = useMutation({
mutationFn: createBudget,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["budget-summary"] });
qc.invalidateQueries({ queryKey: ["budgets"] });
setShowForm(false);
},
});
const deleteMutation = useMutation({
mutationFn: deleteBudget,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["budget-summary"] });
qc.invalidateQueries({ queryKey: ["budgets"] });
},
});
const overBudget = summary.filter((s) => s.is_over_budget).length;
const alerted = summary.filter((s) => s.alert_triggered && !s.is_over_budget).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Budgets</h1>
<p className="text-sm text-muted-foreground mt-1">
{summary.length} active budget{summary.length !== 1 ? "s" : ""}
{overBudget > 0 && (
<span className="ml-2 text-destructive font-medium">· {overBudget} over budget</span>
)}
{alerted > 0 && (
<span className="ml-2 text-orange-500 font-medium">· {alerted} near limit</span>
)}
</p>
</div>
<button
onClick={() => setShowForm(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 Budget
</button>
</div>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 bg-card border border-border rounded-xl animate-pulse" />
))}
</div>
) : summary.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<p className="font-medium">No budgets yet</p>
<p className="text-sm mt-1">Create a budget to start tracking your spending</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{summary.map((item) => (
<div
key={item.budget_id}
className={cn(
"bg-card border rounded-xl p-5 relative group",
item.is_over_budget
? "border-destructive/50"
: item.alert_triggered
? "border-orange-500/50"
: "border-border"
)}
>
<button
onClick={() => deleteMutation.mutate(item.budget_id)}
className="absolute top-3 right-3 p-1 rounded text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<div className="flex items-start gap-4">
<RadialGauge percent={Number(item.percent_used)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
{item.is_over_budget ? (
<AlertTriangle className="w-3.5 h-3.5 text-destructive shrink-0" />
) : item.alert_triggered ? (
<AlertTriangle className="w-3.5 h-3.5 text-orange-500 shrink-0" />
) : (
<CheckCircle className="w-3.5 h-3.5 text-success shrink-0" />
)}
<p className="font-semibold text-sm truncate">{item.budget_name}</p>
</div>
<p className="text-xs text-muted-foreground mb-2">{item.category_name} · {item.period}</p>
<div className="space-y-0.5">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Spent</span>
<span className={cn("font-medium", item.is_over_budget ? "text-destructive" : "")}>
{formatCurrency(item.spent_amount, item.currency)}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Budget</span>
<span className="font-medium">{formatCurrency(item.budget_amount, item.currency)}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Remaining</span>
<span className={cn("font-medium", item.remaining_amount < 0 ? "text-destructive" : "text-success")}>
{formatCurrency(Math.abs(item.remaining_amount), item.currency)}
{item.remaining_amount < 0 ? " over" : ""}
</span>
</div>
</div>
</div>
</div>
<div className="mt-3 text-xs text-muted-foreground">
{item.period_start} {item.period_end}
</div>
</div>
))}
</div>
)}
{showForm && (
<BudgetFormModal
categories={categories}
onClose={() => setShowForm(false)}
onSubmit={(data) => createMutation.mutate(data)}
isLoading={createMutation.isPending}
/>
)}
</div>
);
}