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:
commit
61a7884ee5
127 changed files with 13323 additions and 0 deletions
0
backend/app/workers/__init__.py
Normal file
0
backend/app/workers/__init__.py
Normal file
74
backend/app/workers/fx_sync.py
Normal file
74
backend/app/workers/fx_sync.py
Normal 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))
|
||||
31
backend/app/workers/price_sync.py
Normal file
31
backend/app/workers/price_sync.py
Normal 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))
|
||||
33
backend/app/workers/scheduler.py
Normal file
33
backend/app/workers/scheduler.py
Normal 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")
|
||||
23
backend/app/workers/snapshot.py
Normal file
23
backend/app/workers/snapshot.py
Normal 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue