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,107 @@
import { api } from "./client";
export interface Account {
id: string;
name: string;
institution: string | null;
type: string;
currency: string;
current_balance: number;
credit_limit: number | null;
interest_rate: number | null;
is_active: boolean;
include_in_net_worth: boolean;
color: string;
icon: string | null;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface AccountCreate {
name: string;
institution?: string;
type: string;
currency?: string;
credit_limit?: number;
interest_rate?: number;
include_in_net_worth?: boolean;
color?: string;
opening_balance?: number;
notes?: string;
}
export async function getAccounts(): Promise<Account[]> {
const res = await api.get("/accounts");
return res.data;
}
export async function createAccount(data: AccountCreate): Promise<Account> {
const res = await api.post("/accounts", data);
return res.data;
}
export async function updateAccount(id: string, data: Partial<AccountCreate>): Promise<Account> {
const res = await api.put(`/accounts/${id}`, data);
return res.data;
}
export async function deleteAccount(id: string): Promise<void> {
await api.delete(`/accounts/${id}`);
}
export interface CsvMapping {
date: string;
description: string;
amount: string | null;
debit: string | null;
credit: string | null;
balance: string | null;
reference: string | null;
}
export interface ImportPreview {
detected_format: string | null;
headers: string[];
mapping: CsvMapping;
total_rows: number;
preview: {
date_raw: string;
description_raw: string;
amount_raw: number | null;
balance_raw?: string;
}[];
}
export async function previewImport(accountId: string, file: File): Promise<ImportPreview> {
const form = new FormData();
form.append("file", file);
const res = await api.post(`/accounts/${accountId}/import/preview`, form);
return res.data;
}
export async function importCsvToAccount(
accountId: string,
file: File,
mapping: CsvMapping
): Promise<{ imported: number; skipped: number }> {
const form = new FormData();
form.append("file", file);
form.append("date_col", mapping.date);
form.append("description_col", mapping.description);
form.append("amount_col", mapping.amount ?? "");
form.append("debit_col", mapping.debit ?? "");
form.append("credit_col", mapping.credit ?? "");
const res = await api.post(`/accounts/${accountId}/import`, form);
return res.data;
}
export async function getNetWorth(): Promise<{
total_assets: number;
total_liabilities: number;
net_worth: number;
base_currency: string;
}> {
const res = await api.get("/accounts/net-worth");
return res.data;
}

78
frontend/src/api/auth.ts Normal file
View file

@ -0,0 +1,78 @@
import { api } from "./client";
export interface LoginResponse {
access_token?: string;
token_type?: string;
expires_in?: number;
totp_required?: boolean;
challenge_token?: string;
}
export async function login(email: string, password: string): Promise<LoginResponse> {
const res = await api.post("/auth/login", { email, password });
return res.data;
}
export async function loginTotp(challengeToken: string, totpCode: string): Promise<LoginResponse> {
const res = await api.post("/auth/login/totp", {
challenge_token: challengeToken,
totp_code: totpCode,
});
return res.data;
}
export async function logout(): Promise<void> {
await api.post("/auth/logout");
}
export async function getMe() {
const res = await api.get("/users/me");
return res.data;
}
export async function getTotpSetup() {
const res = await api.get("/auth/totp/setup");
return res.data as { secret: string; qr_code_png_b64: string; backup_codes: string[] };
}
export async function enableTotp(secret: string, code: string): Promise<void> {
await api.post("/auth/totp/enable", { secret, code });
}
export async function getSessions() {
const res = await api.get("/auth/sessions");
return res.data;
}
export async function revokeSession(sessionId: string): Promise<void> {
await api.delete(`/auth/sessions/${sessionId}`);
}
export async function revokeAllSessions(): Promise<void> {
await api.post("/auth/logout-all");
}
export async function disableTotp(password: string): Promise<void> {
await api.delete("/auth/totp", { data: { password } });
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
await api.post("/users/me/password", {
current_password: currentPassword,
new_password: newPassword,
});
}
export async function updateProfile(data: { display_name?: string; base_currency?: string }): Promise<void> {
await api.put("/users/me", data);
}
export async function exportData(): Promise<void> {
const res = await api.get("/users/me/export", { responseType: "blob" });
const url = URL.createObjectURL(res.data);
const a = document.createElement("a");
a.href = url;
a.download = `transactions_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,65 @@
import { api } from "./client";
export interface Budget {
id: string;
category_id: string;
name: string;
amount: number;
currency: string;
period: string;
start_date: string;
end_date: string | null;
rollover: boolean;
alert_threshold: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface BudgetSummaryItem {
budget_id: string;
budget_name: string;
category_id: string;
category_name: string;
period: string;
budget_amount: number;
spent_amount: number;
remaining_amount: number;
percent_used: number;
is_over_budget: boolean;
alert_triggered: boolean;
currency: string;
period_start: string;
period_end: string;
}
export interface BudgetCreate {
category_id: string;
name: string;
amount: number;
currency?: string;
period: "weekly" | "monthly" | "quarterly" | "yearly";
start_date: string;
end_date?: string | null;
rollover?: boolean;
alert_threshold?: number;
}
export async function getBudgets(activeOnly = true): Promise<Budget[]> {
const r = await api.get("/api/v1/budgets", { params: { active_only: activeOnly } });
return r.data;
}
export async function getBudgetSummary(): Promise<BudgetSummaryItem[]> {
const r = await api.get("/api/v1/budgets/summary");
return r.data;
}
export async function createBudget(data: BudgetCreate): Promise<Budget> {
const r = await api.post("/api/v1/budgets", data);
return r.data;
}
export async function deleteBudget(id: string): Promise<void> {
await api.delete(`/api/v1/budgets/${id}`);
}

View file

@ -0,0 +1,56 @@
import axios from "axios";
import { useAuthStore } from "@/store/authStore";
export const api = axios.create({
baseURL: "/api/v1",
withCredentials: true,
});
// Attach bearer token + CSRF header to every request
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// CSRF double-submit: read cookie and send as header
const csrfCookie = document.cookie
.split("; ")
.find((row) => row.startsWith("csrf_token="))
?.split("=")[1];
if (csrfCookie && config.method !== "get") {
config.headers["X-CSRF-Token"] = csrfCookie;
}
return config;
});
// Auto-refresh on 401
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
try {
const res = await axios.post(
"/api/v1/auth/refresh",
{},
{ withCredentials: true }
);
const { access_token } = res.data;
// Update store - we need to get current user info from the existing token
const store = useAuthStore.getState();
if (store.userId && store.displayName) {
store.setToken(access_token, store.userId, store.displayName);
}
original.headers.Authorization = `Bearer ${access_token}`;
return api(original);
} catch {
useAuthStore.getState().clearAuth();
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);

View file

@ -0,0 +1,111 @@
import { api } from "./client";
export interface AssetSearchResult {
id: string;
symbol: string;
name: string;
type: string;
currency: string;
exchange: string | null;
last_price: number | null;
price_change_24h: number | null;
data_source: string;
}
export interface HoldingResponse {
id: string;
account_id: string;
asset_id: string;
symbol: string;
asset_name: string;
asset_type: string;
quantity: number;
avg_cost_basis: number;
current_price: number | null;
current_value: number | null;
cost_basis_total: number;
unrealised_gain: number | null;
unrealised_gain_pct: number | null;
currency: string;
price_change_24h: number | null;
}
export interface PortfolioSummary {
total_value: number;
total_cost: number;
total_gain: number;
total_gain_pct: number;
currency: string;
holdings: HoldingResponse[];
}
export interface InvestmentTxn {
id: string;
holding_id: string;
type: string;
quantity: number;
price: number;
fees: number;
total_amount: number;
currency: string;
date: string;
created_at: string;
}
export interface PricePoint {
date: string;
open: number | null;
high: number | null;
low: number | null;
close: number;
volume: number | null;
}
export async function getPortfolio(): Promise<PortfolioSummary> {
const r = await api.get("/api/v1/investments/portfolio");
return r.data;
}
export async function searchAssets(q: string): Promise<AssetSearchResult[]> {
const r = await api.get("/api/v1/assets/search", { params: { q } });
return r.data;
}
export async function getPriceHistory(assetId: string, days = 365): Promise<PricePoint[]> {
const r = await api.get(`/api/v1/assets/${assetId}/prices`, { params: { days } });
return r.data;
}
export async function createHolding(data: {
account_id: string;
asset_id: string;
quantity: number;
avg_cost_basis: number;
currency?: string;
}): Promise<HoldingResponse> {
const r = await api.post("/api/v1/investments/holdings", data);
return r.data;
}
export async function deleteHolding(id: string): Promise<void> {
await api.delete(`/api/v1/investments/holdings/${id}`);
}
export async function addInvestmentTransaction(data: {
holding_id: string;
type: string;
quantity: number;
price: number;
fees?: number;
currency?: string;
date: string;
notes?: string;
}): Promise<InvestmentTxn> {
const r = await api.post("/api/v1/investments/transactions", data);
return r.data;
}
export async function getHoldingTransactions(holdingId: string): Promise<InvestmentTxn[]> {
const r = await api.get(`/api/v1/investments/holdings/${holdingId}/transactions`);
return r.data;
}

View file

@ -0,0 +1,109 @@
import { api } from "./client";
export interface CategoryForecast {
category_id: string;
category_name: string;
monthly_avg: number;
actuals: { date: string; amount: number }[];
forecast: { date: string; amount: number; lower: number; upper: number }[];
}
export interface SpendingForecastResponse {
categories: CategoryForecast[];
}
export interface NetWorthProjectionResponse {
history: { date: string; value: number }[];
projections: {
conservative: { date: string; value: number }[];
base: { date: string; value: number }[];
optimistic: { date: string; value: number }[];
};
insufficient_data: boolean;
}
export interface PercentilePath {
date: string;
value: number;
}
export interface MonteCarloResponse {
dates: string[];
percentiles: {
p10: PercentilePath[];
p25: PercentilePath[];
p50: PercentilePath[];
p75: PercentilePath[];
p90: PercentilePath[];
};
current_value: number;
expected_value: number;
probability_of_gain: number;
insufficient_data: boolean;
}
export interface BudgetForecastItem {
category_id: string;
category_name: string;
budget_amount: number;
spent_so_far: number;
forecast_month_total: number;
daily_velocity: number;
probability_overspend: number;
days_remaining: number;
}
export interface BudgetForecastResponse {
forecasts: BudgetForecastItem[];
message?: string;
}
export interface CashFlowDay {
date: string;
balance: number;
avg_inflow: number;
avg_outflow: number;
negative_risk: boolean;
}
export interface CashFlowResponse {
current_balance: number;
avg_daily_inflow: number;
avg_daily_outflow: number;
forecast: CashFlowDay[];
negative_risk_days: string[];
history_days: number;
}
export async function getSpendingForecast(): Promise<SpendingForecastResponse> {
const res = await api.get("/predictions/spending");
return res.data;
}
export async function getNetWorthProjection(years = 5): Promise<NetWorthProjectionResponse> {
const res = await api.get("/predictions/net-worth", { params: { years } });
return res.data;
}
export async function postMonteCarlo(params: {
years?: number;
n_simulations?: number;
annual_contribution?: number;
}): Promise<MonteCarloResponse> {
const res = await api.post("/predictions/monte-carlo", {
years: params.years ?? 5,
n_simulations: params.n_simulations ?? 1000,
annual_contribution: params.annual_contribution ?? 0,
});
return res.data;
}
export async function getBudgetForecast(): Promise<BudgetForecastResponse> {
const res = await api.get("/predictions/budget-forecast");
return res.data;
}
export async function getCashFlowForecast(): Promise<CashFlowResponse> {
const res = await api.get("/predictions/cashflow");
return res.data;
}

123
frontend/src/api/reports.ts Normal file
View file

@ -0,0 +1,123 @@
import { api } from "./client";
export interface NetWorthPoint {
date: string;
total_assets: number;
total_liabilities: number;
net_worth: number;
base_currency: string;
}
export interface NetWorthReport {
points: NetWorthPoint[];
current_net_worth: number;
change_30d: number;
change_30d_pct: number;
base_currency: string;
}
export interface IncomeExpensePoint {
month: string;
income: number;
expenses: number;
net: number;
}
export interface IncomeExpenseReport {
points: IncomeExpensePoint[];
total_income: number;
total_expenses: number;
avg_monthly_income: number;
avg_monthly_expenses: number;
currency: string;
}
export interface CashFlowPoint {
date: string;
inflow: number;
outflow: number;
net: number;
running_balance: number;
}
export interface CashFlowReport {
points: CashFlowPoint[];
total_inflow: number;
total_outflow: number;
currency: string;
}
export interface CategoryBreakdownItem {
category_id: string | null;
category_name: string;
amount: number;
percent: number;
transaction_count: number;
}
export interface CategoryBreakdownReport {
items: CategoryBreakdownItem[];
total: number;
currency: string;
date_from: string;
date_to: string;
}
export interface BudgetVsActualReport {
items: Array<{
budget_id: string;
budget_name: string;
category_name: string;
budgeted: number;
actual: number;
variance: number;
percent_used: number;
}>;
total_budgeted: number;
total_actual: number;
currency: string;
}
export interface SpendingTrendsReport {
points: Array<{ month: string; category_name: string; amount: number }>;
categories: string[];
currency: string;
}
export async function getNetWorthReport(months = 12): Promise<NetWorthReport> {
const r = await api.get("/api/v1/reports/net-worth", { params: { months } });
return r.data;
}
export async function getIncomeExpenseReport(months = 12): Promise<IncomeExpenseReport> {
const r = await api.get("/api/v1/reports/income-vs-expense", { params: { months } });
return r.data;
}
export async function getCashFlowReport(dateFrom?: string, dateTo?: string): Promise<CashFlowReport> {
const r = await api.get("/api/v1/reports/cash-flow", {
params: { date_from: dateFrom, date_to: dateTo },
});
return r.data;
}
export async function getCategoryBreakdown(
dateFrom?: string,
dateTo?: string,
type = "expense"
): Promise<CategoryBreakdownReport> {
const r = await api.get("/api/v1/reports/category-breakdown", {
params: { date_from: dateFrom, date_to: dateTo, type },
});
return r.data;
}
export async function getBudgetVsActual(): Promise<BudgetVsActualReport> {
const r = await api.get("/api/v1/reports/budget-vs-actual");
return r.data;
}
export async function getSpendingTrends(months = 6): Promise<SpendingTrendsReport> {
const r = await api.get("/api/v1/reports/spending-trends", { params: { months } });
return r.data;
}

View file

@ -0,0 +1,128 @@
import { api } from "./client";
export interface AttachmentRef {
id: string;
filename: string;
mime_type: string;
size: number;
stored_name: string;
}
export interface Transaction {
id: string;
account_id: string;
transfer_account_id: string | null;
category_id: string | null;
type: "income" | "expense" | "transfer" | "investment";
status: "pending" | "cleared" | "reconciled" | "void";
amount: number;
amount_base: number | null;
currency: string;
base_currency: string;
date: string;
description: string;
merchant: string | null;
notes: string | null;
tags: string[];
is_recurring: boolean;
attachment_refs: AttachmentRef[];
created_at: string;
updated_at: string;
}
export interface TransactionCreate {
account_id: string;
transfer_account_id?: string;
category_id?: string;
type: Transaction["type"];
status?: Transaction["status"];
amount: number;
currency?: string;
date: string;
description: string;
merchant?: string;
notes?: string;
tags?: string[];
}
export interface TransactionPage {
items: Transaction[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface TransactionFilters {
account_id?: string;
category_id?: string;
type?: string;
status?: string;
date_from?: string;
date_to?: string;
search?: string;
page?: number;
page_size?: number;
}
export async function getTransactions(filters: TransactionFilters = {}): Promise<TransactionPage> {
const params = Object.fromEntries(
Object.entries(filters).filter(([, v]) => v !== undefined && v !== "")
);
const res = await api.get("/transactions", { params });
return res.data;
}
export async function createTransaction(data: TransactionCreate): Promise<Transaction> {
const res = await api.post("/transactions", data);
return res.data;
}
export async function updateTransaction(id: string, data: Partial<TransactionCreate>): Promise<Transaction> {
const res = await api.put(`/transactions/${id}`, data);
return res.data;
}
export async function deleteTransaction(id: string): Promise<void> {
await api.delete(`/transactions/${id}`);
}
export async function getCategories(): Promise<{ id: string; name: string; type: string; icon: string | null; color: string | null }[]> {
const res = await api.get("/categories");
return res.data;
}
export async function importCsv(
file: File,
accountId: string,
colMap: { date: string; description: string; amount: string }
): Promise<{ imported: number; skipped: number }> {
const form = new FormData();
form.append("file", file);
form.append("account_id", accountId);
form.append("date_col", colMap.date);
form.append("description_col", colMap.description);
form.append("amount_col", colMap.amount);
const res = await api.post("/transactions/import", form, {
headers: { "Content-Type": "multipart/form-data" },
});
return res.data;
}
export async function uploadAttachment(txnId: string, file: File): Promise<AttachmentRef> {
const form = new FormData();
form.append("file", file);
const res = await api.post(`/transactions/${txnId}/attachments`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
return res.data;
}
export function getAttachmentUrl(txnId: string, attachmentId: string): string {
const base = (api.defaults.baseURL ?? "").replace(/\/$/, "");
return `${base}/transactions/${txnId}/attachments/${attachmentId}`;
}
export async function deleteAttachment(txnId: string, attachmentId: string): Promise<void> {
await api.delete(`/transactions/${txnId}/attachments/${attachmentId}`);
}