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

View file

@ -0,0 +1,74 @@
import structlog
logger = structlog.get_logger()
PAIRS = [
("GBP", "USD"), ("GBP", "EUR"), ("GBP", "JPY"), ("GBP", "CAD"),
("GBP", "AUD"), ("GBP", "CHF"), ("USD", "GBP"), ("EUR", "GBP"),
]
async def fx_sync_job() -> None:
from app.dependencies import get_session_factory
session_factory = get_session_factory()
if not session_factory:
return
try:
import requests
r = requests.get(
"https://api.exchangerate-api.com/v4/latest/GBP",
timeout=10,
)
r.raise_for_status()
data = r.json()
rates = data.get("rates", {})
except Exception as exc:
logger.error("fx_fetch_failed", error=str(exc))
return
from datetime import datetime, timezone
from decimal import Decimal
import uuid as _uuid
from sqlalchemy import select
from app.db.models.currency import ExchangeRate # type: ignore[attr-defined]
async with session_factory() as db:
try:
now = datetime.now(timezone.utc)
for base, quote in PAIRS:
if base == "GBP":
rate_val = rates.get(quote)
else:
gbp_to_base = rates.get(base)
if not gbp_to_base or gbp_to_base == 0:
continue
rate_val = 1 / gbp_to_base
if not rate_val:
continue
result = await db.execute(
select(ExchangeRate).where(
ExchangeRate.base_currency == base,
ExchangeRate.quote_currency == quote,
)
)
existing = result.scalar_one_or_none()
if existing:
existing.rate = Decimal(str(round(rate_val, 8)))
existing.fetched_at = now
else:
db.add(ExchangeRate(
id=_uuid.uuid4(),
base_currency=base,
quote_currency=quote,
rate=Decimal(str(round(rate_val, 8))),
source="exchangerate-api",
fetched_at=now,
))
await db.commit()
logger.info("fx_sync_done", pairs=len(PAIRS))
except Exception as exc:
await db.rollback()
logger.error("fx_sync_db_failed", error=str(exc))

View file

@ -0,0 +1,31 @@
import structlog
from sqlalchemy import select
logger = structlog.get_logger()
async def price_sync_job() -> None:
from app.dependencies import get_session_factory
from app.db.models.asset import Asset
from app.services.price_feed_service import fetch_price
from app.services.investment_service import update_asset_price
session_factory = get_session_factory()
if not session_factory:
return
async with session_factory() as db:
try:
result = await db.execute(select(Asset).where(Asset.is_active == True)) # noqa: E712
assets = result.scalars().all()
updated = 0
for asset in assets:
data = await fetch_price(asset.symbol, asset.data_source, asset.data_source_id)
if data and data.get("price"):
await update_asset_price(db, asset, data["price"], data.get("change_24h"))
updated += 1
await db.commit()
logger.info("price_sync_done", updated=updated, total=len(assets))
except Exception as exc:
await db.rollback()
logger.error("price_sync_failed", error=str(exc))

View file

@ -0,0 +1,33 @@
"""
APScheduler background jobs. Starts with the FastAPI lifespan.
"""
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
import structlog
logger = structlog.get_logger()
_scheduler: AsyncIOScheduler | None = None
async def start_scheduler() -> None:
global _scheduler
from app.workers.snapshot import snapshot_job
from app.workers.price_sync import price_sync_job
from app.workers.fx_sync import fx_sync_job
_scheduler = AsyncIOScheduler()
_scheduler.add_job(snapshot_job, CronTrigger(hour=2, minute=0), id="nw_snapshot")
_scheduler.add_job(price_sync_job, CronTrigger(minute="*/15"), id="price_sync")
_scheduler.add_job(fx_sync_job, CronTrigger(minute=0), id="fx_sync")
# _scheduler.add_job(backup_job, CronTrigger(hour=3), id="backup")
# _scheduler.add_job(ml_retrain_job, CronTrigger(day_of_week="sun", hour=1), id="ml_retrain")
_scheduler.start()
logger.info("scheduler_started")
async def stop_scheduler() -> None:
if _scheduler and _scheduler.running:
_scheduler.shutdown(wait=False)
logger.info("scheduler_stopped")

View file

@ -0,0 +1,23 @@
import structlog
from sqlalchemy import select
logger = structlog.get_logger()
async def snapshot_job() -> None:
from app.dependencies import get_session_factory
from app.db.models.user import User
from app.services.report_service import take_net_worth_snapshot
session_factory = get_session_factory()
async with session_factory() as db:
try:
result = await db.execute(select(User).where(User.deleted_at.is_(None)))
users = result.scalars().all()
for user in users:
await take_net_worth_snapshot(db, user.id, user.base_currency)
await db.commit()
logger.info("snapshot_job_done", users=len(users))
except Exception as exc:
await db.rollback()
logger.error("snapshot_job_failed", error=str(exc))