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

14
backend/app/api/router.py Normal file
View file

@ -0,0 +1,14 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions
router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["auth"])
router.include_router(users.router, prefix="/users", tags=["users"])
router.include_router(accounts.router, prefix="/accounts", tags=["accounts"])
router.include_router(categories.router, prefix="/categories", tags=["categories"])
router.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
router.include_router(budgets.router)
router.include_router(reports.router)
router.include_router(investments.router)
router.include_router(predictions.router)

View file

View file

@ -0,0 +1,236 @@
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import write_audit
from app.dependencies import get_current_user, get_db
from app.schemas.account import AccountCreate, AccountResponse, AccountUpdate
from app.services.account_service import (
AccountError,
create_account,
delete_account,
get_account,
get_net_worth,
list_accounts,
update_account,
)
MAX_IMPORT_FILE_BYTES = 10 * 1024 * 1024 # 10 MB
MAX_IMPORT_ROWS = 50_000
router = APIRouter()
@router.get("", response_model=list[AccountResponse])
async def get_accounts(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
return await list_accounts(db, user.id)
@router.post("", response_model=AccountResponse, status_code=201)
async def create(
body: AccountCreate,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
result = await create_account(db, user.id, body)
await write_audit(db, user_id=user.id, action="account_create")
await db.commit()
return result
@router.get("/net-worth")
async def net_worth(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
return await get_net_worth(db, user.id, user.base_currency)
@router.get("/{account_id}", response_model=AccountResponse)
async def get_one(
account_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
account = await get_account(db, account_id, user.id)
from app.services.account_service import _to_response
return _to_response(account)
except AccountError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
@router.put("/{account_id}", response_model=AccountResponse)
async def update(
account_id: uuid.UUID,
body: AccountUpdate,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
result = await update_account(db, account_id, user.id, body)
await write_audit(db, user_id=user.id, action="account_update", resource_type="account", resource_id=account_id)
await db.commit()
return result
except AccountError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
@router.post("/{account_id}/import/preview")
async def import_preview(
account_id: uuid.UUID,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
"""Upload a CSV and get back the detected format, column mapping, and a sample of parsed rows."""
from app.services.csv_detector import parse_csv_content, detect_format
try:
await get_account(db, account_id, user.id)
except AccountError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
content = await file.read(MAX_IMPORT_FILE_BYTES + 1)
if len(content) > MAX_IMPORT_FILE_BYTES:
raise HTTPException(status_code=413, detail="File too large (max 10 MB)")
try:
headers, rows = parse_csv_content(content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not headers:
raise HTTPException(status_code=400, detail="Could not read CSV headers")
mapping = detect_format(headers)
# Build 5-row preview using the detected mapping
preview = []
for row in rows[:5]:
entry: dict = {
"date_raw": row.get(mapping.date, ""),
"description_raw": row.get(mapping.description, ""),
}
if mapping.is_split():
debit_str = row.get(mapping.debit or "", "").replace(",", "").replace("£", "").strip()
credit_str = row.get(mapping.credit or "", "").replace(",", "").replace("£", "").strip()
try:
debit = float(debit_str) if debit_str else 0.0
credit = float(credit_str) if credit_str else 0.0
entry["amount_raw"] = credit - debit
except ValueError:
entry["amount_raw"] = None
else:
raw = row.get(mapping.amount or "", "").replace(",", "").replace("£", "").strip()
try:
entry["amount_raw"] = float(raw) if raw else None
except ValueError:
entry["amount_raw"] = None
if mapping.balance:
entry["balance_raw"] = row.get(mapping.balance, "")
preview.append(entry)
return {
"detected_format": mapping.detected_format,
"headers": headers,
"mapping": {
"date": mapping.date,
"description": mapping.description,
"amount": mapping.amount,
"debit": mapping.debit,
"credit": mapping.credit,
"balance": mapping.balance,
"reference": mapping.reference,
},
"total_rows": len(rows),
"preview": preview,
}
@router.post("/{account_id}/import")
async def import_csv_to_account(
account_id: uuid.UUID,
file: UploadFile = File(...),
date_col: str = Form(...),
description_col: str = Form(...),
amount_col: str = Form(default=""),
debit_col: str = Form(default=""),
credit_col: str = Form(default=""),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
from app.services.csv_detector import parse_csv_content
from app.services.transaction_service import import_csv
from app.core.audit import write_audit
try:
await get_account(db, account_id, user.id)
except AccountError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
content = await file.read(MAX_IMPORT_FILE_BYTES + 1)
if len(content) > MAX_IMPORT_FILE_BYTES:
raise HTTPException(status_code=413, detail="File too large (max 10 MB)")
try:
_, rows = parse_csv_content(content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if len(rows) > MAX_IMPORT_ROWS:
raise HTTPException(status_code=400, detail=f"File contains too many rows (max {MAX_IMPORT_ROWS:,})")
use_split = bool(debit_col and credit_col)
parsed_rows = []
for row in rows:
date_val = row.get(date_col, "").strip()
desc_val = row.get(description_col, "").strip() or "Imported transaction"
if use_split:
debit_str = row.get(debit_col, "").replace(",", "").replace("£", "").strip()
credit_str = row.get(credit_col, "").replace(",", "").replace("£", "").strip()
try:
debit = float(debit_str) if debit_str else 0.0
credit = float(credit_str) if credit_str else 0.0
amount = credit - debit
except ValueError:
continue
else:
raw = row.get(amount_col, "").replace(",", "").replace("£", "").strip()
try:
amount = float(raw) if raw else None
except ValueError:
continue
if amount is None:
continue
if not date_val:
continue
parsed_rows.append({"date": date_val, "description": desc_val, "amount": str(amount)})
if not parsed_rows:
raise HTTPException(status_code=400, detail="No valid rows found after applying column mapping")
result = await import_csv(db, user.id, account_id, parsed_rows, user.base_currency)
await write_audit(db, user_id=user.id, action="import_data", metadata=result)
await db.commit()
return result
@router.delete("/{account_id}", status_code=204)
async def delete(
account_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
await delete_account(db, account_id, user.id)
await write_audit(db, user_id=user.id, action="account_delete", resource_type="account", resource_id=account_id)
await db.commit()
except AccountError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)

342
backend/app/api/v1/auth.py Normal file
View file

@ -0,0 +1,342 @@
"""
Auth endpoints: register, login, TOTP, refresh, logout, sessions.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import write_audit
from app.core.rate_limiter import is_rate_limited
from app.core.security import create_refresh_token, decode_token, generate_csrf_token, hash_token
from app.dependencies import get_current_user, get_db, get_redis
from app.schemas.auth import (
LoginRequest,
RegisterRequest,
SessionInfo,
TOTPChallengeResponse,
TOTPLoginRequest,
TOTPSetupResponse,
TOTPVerifyRequest,
TokenResponse,
)
from app.services.auth_service import (
AuthError,
authenticate_user,
complete_totp_login,
create_totp_challenge_token,
disable_totp,
enable_totp,
get_sessions,
register_user,
revoke_all_sessions,
revoke_session,
setup_totp,
)
router = APIRouter()
def _ip(request: Request) -> str | None:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else None
def _ua(request: Request) -> str | None:
return request.headers.get("User-Agent")
def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
"refresh_token",
token,
httponly=True,
secure=True,
samesite="strict",
max_age=7 * 24 * 3600,
path="/api/v1/auth",
)
def _set_csrf_cookie(response: Response, token: str) -> None:
response.set_cookie(
"csrf_token",
token,
httponly=False,
secure=True,
samesite="strict",
max_age=86400,
)
@router.post("/register", status_code=201)
async def register(
body: RegisterRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
try:
user = await register_user(db, body.email, body.password, body.display_name)
await write_audit(db, user_id=user.id, action="register", ip_address=_ip(request))
await db.commit()
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
return {"message": "Account created. Please log in."}
@router.post("/login")
async def login(
body: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
):
try:
user, access_token, refresh_token = await authenticate_user(
db, redis, body.email, body.password, _ip(request), _ua(request)
)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
if access_token is None:
# TOTP required
challenge_token = create_totp_challenge_token(user.id)
await write_audit(db, user_id=user.id, action="login", ip_address=_ip(request), metadata={"totp_required": True})
await db.commit()
return TOTPChallengeResponse(challenge_token=challenge_token)
csrf = generate_csrf_token()
_set_refresh_cookie(response, refresh_token)
_set_csrf_cookie(response, csrf)
await write_audit(db, user_id=user.id, action="login", ip_address=_ip(request))
await db.commit()
settings_expire = 15 * 60
return TokenResponse(access_token=access_token, expires_in=settings_expire)
@router.post("/login/totp")
async def login_totp(
body: TOTPLoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
):
ip = _ip(request) or "unknown"
limited, _ = await is_rate_limited(redis, f"rate:totp:{ip}", limit=10, window_seconds=60)
if limited:
raise HTTPException(status_code=429, detail="Too many TOTP attempts — try again shortly")
try:
access_token, refresh_token = await complete_totp_login(
db, body.challenge_token, body.totp_code, _ip(request), _ua(request)
)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
csrf = generate_csrf_token()
_set_refresh_cookie(response, refresh_token)
_set_csrf_cookie(response, csrf)
await db.commit()
return TokenResponse(access_token=access_token, expires_in=15 * 60)
@router.post("/refresh")
async def refresh_token(
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
token = request.cookies.get("refresh_token")
if not token:
raise HTTPException(status_code=401, detail="No refresh token")
try:
payload = decode_token(token, token_type="refresh")
except Exception:
raise HTTPException(status_code=401, detail="Invalid refresh token")
import uuid
from app.core.security import create_access_token
from sqlalchemy import select
from datetime import datetime, timezone
from app.db.models.session import Session
user_id = uuid.UUID(payload["sub"])
now = datetime.now(timezone.utc)
# Find and update session
result = await db.execute(
select(Session).where(
Session.user_id == user_id,
Session.revoked_at.is_(None),
Session.expires_at > now,
)
)
session = result.scalars().first()
if not session:
raise HTTPException(status_code=401, detail="Session not found")
new_access = create_access_token(str(user_id))
new_refresh = create_refresh_token(str(user_id))
# Rotate session token hash
session.token_hash = hash_token(new_access)
session.last_active_at = now
await db.commit()
csrf = generate_csrf_token()
_set_refresh_cookie(response, new_refresh)
_set_csrf_cookie(response, csrf)
return TokenResponse(access_token=new_access, expires_in=15 * 60)
@router.post("/logout")
async def logout(
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
token = request.headers.get("Authorization", "")[7:]
th = hash_token(token)
await revoke_session_by_hash(db, th, user.id)
await write_audit(db, user_id=user.id, action="logout", ip_address=_ip(request))
await db.commit()
response.delete_cookie("refresh_token", path="/api/v1/auth")
response.delete_cookie("csrf_token")
return {"message": "Logged out"}
async def revoke_session_by_hash(db, token_hash: str, user_id):
from sqlalchemy import select, update
from datetime import datetime, timezone
from app.db.models.session import Session
await db.execute(
update(Session)
.where(Session.user_id == user_id, Session.token_hash == token_hash)
.values(revoked_at=datetime.now(timezone.utc))
)
@router.post("/logout-all")
async def logout_all(
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
token = request.headers.get("Authorization", "")[7:]
await revoke_all_sessions(db, user.id)
await write_audit(db, user_id=user.id, action="logout_all", ip_address=_ip(request))
await db.commit()
response.delete_cookie("refresh_token", path="/api/v1/auth")
response.delete_cookie("csrf_token")
return {"message": "All sessions revoked"}
@router.get("/sessions", response_model=list[SessionInfo])
async def list_sessions(
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
token = request.headers.get("Authorization", "")[7:]
current_hash = hash_token(token)
sessions = await get_sessions(db, user.id)
result = []
for s in sessions:
info = SessionInfo.model_validate(s)
info.is_current = (s.token_hash == current_hash)
result.append(info)
return result
@router.delete("/sessions/{session_id}", status_code=204)
async def delete_session(
session_id,
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
import uuid
try:
sid = uuid.UUID(str(session_id))
except ValueError:
raise HTTPException(status_code=422, detail="Invalid session ID")
try:
await revoke_session(db, sid, user.id)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
await write_audit(db, user_id=user.id, action="session_revoke", resource_type="session", resource_id=sid)
await db.commit()
@router.get("/totp/setup", response_model=TOTPSetupResponse)
async def totp_setup(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
secret, qr_b64, backup_codes = await setup_totp(user, db)
return TOTPSetupResponse(secret=secret, qr_code_png_b64=qr_b64, backup_codes=backup_codes)
@router.post("/totp/verify", status_code=200)
async def totp_verify(
body: TOTPVerifyRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
# Secret must be passed back from setup — here we expect it stored temporarily in body
# In practice the client stores it until verification; it's never persisted until verified
# This endpoint receives the secret + verification code
# For simplicity we accept: {"secret": "...", "code": "..."}
# Redefine body inline:
raise HTTPException(status_code=400, detail="Use /totp/enable endpoint with secret and code")
@router.post("/totp/enable", status_code=200)
async def totp_enable(
body: dict,
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
secret = body.get("secret")
code = body.get("code")
if not secret or not code:
raise HTTPException(status_code=422, detail="secret and code required")
try:
await enable_totp(user, db, secret, code)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
await write_audit(db, user_id=user.id, action="totp_enable", ip_address=_ip(request))
await db.commit()
return {"message": "TOTP enabled"}
@router.delete("/totp", status_code=200)
async def totp_disable(
body: dict,
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
password = body.get("password")
if not password:
raise HTTPException(status_code=422, detail="password required")
try:
await disable_totp(user, db, password)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
await write_audit(db, user_id=user.id, action="totp_disable", ip_address=_ip(request))
await db.commit()
return {"message": "TOTP disabled"}

View file

@ -0,0 +1,79 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.db.models.user import User
from app.schemas.budget import BudgetCreate, BudgetResponse, BudgetSummaryItem, BudgetUpdate
from app.services import budget_service
router = APIRouter(prefix="/budgets", tags=["budgets"])
@router.get("", response_model=list[BudgetResponse])
async def list_budgets(
active_only: bool = True,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await budget_service.list_budgets(db, current_user.id, active_only)
@router.post("", response_model=BudgetResponse, status_code=status.HTTP_201_CREATED)
async def create_budget(
data: BudgetCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
budget = await budget_service.create_budget(db, current_user.id, data)
await db.commit()
return budget
@router.get("/summary", response_model=list[BudgetSummaryItem])
async def get_budget_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await budget_service.get_budget_summary(db, current_user.id)
@router.get("/{budget_id}", response_model=BudgetResponse)
async def get_budget(
budget_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
budget = await budget_service.get_budget(db, current_user.id, budget_id)
if not budget:
raise HTTPException(status_code=404, detail="Budget not found")
return budget
@router.put("/{budget_id}", response_model=BudgetResponse)
async def update_budget(
budget_id: uuid.UUID,
data: BudgetUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
budget = await budget_service.get_budget(db, current_user.id, budget_id)
if not budget:
raise HTTPException(status_code=404, detail="Budget not found")
budget = await budget_service.update_budget(db, budget, data)
await db.commit()
return budget
@router.delete("/{budget_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_budget(
budget_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
budget = await budget_service.get_budget(db, current_user.id, budget_id)
if not budget:
raise HTTPException(status_code=404, detail="Budget not found")
await budget_service.delete_budget(db, budget)
await db.commit()

View file

@ -0,0 +1,36 @@
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.services.category_service import create_category, list_categories
router = APIRouter()
@router.get("")
async def get_categories(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
return await list_categories(db, user.id)
@router.post("", status_code=201)
async def create(
body: dict,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
result = await create_category(
db,
user_id=user.id,
name=body["name"],
type_=body["type"],
icon=body.get("icon"),
color=body.get("color"),
parent_id=uuid.UUID(body["parent_id"]) if body.get("parent_id") else None,
)
await db.commit()
return result

View file

@ -0,0 +1,199 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.db.models.user import User
from app.schemas.investment import (
AssetSearch,
AssetPricePoint,
HoldingCreate,
HoldingResponse,
InvestmentTxnCreate,
InvestmentTxnResponse,
PerformanceMetrics,
PortfolioSummary,
)
from app.services import investment_service
from app.services.price_feed_service import search_yahoo, fetch_history
router = APIRouter(tags=["investments"])
# ── Portfolio ──────────────────────────────────────────────────────────────
@router.get("/investments/portfolio", response_model=PortfolioSummary)
async def get_portfolio(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await investment_service.get_portfolio(db, current_user.id)
@router.get("/investments/performance", response_model=PerformanceMetrics)
async def get_performance(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await investment_service.get_performance(db, current_user.id)
# ── Holdings ───────────────────────────────────────────────────────────────
@router.post("/investments/holdings", response_model=HoldingResponse, status_code=status.HTTP_201_CREATED)
async def create_holding(
data: HoldingCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.db.models.asset import Asset
from sqlalchemy import select
asset_result = await db.execute(select(Asset).where(Asset.id == data.asset_id))
asset = asset_result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
holding = await investment_service.create_holding(db, current_user.id, data)
await db.commit()
await db.refresh(holding)
return investment_service._holding_to_response(holding, asset)
@router.delete("/investments/holdings/{holding_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_holding(
holding_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
holding = await investment_service.get_holding(db, current_user.id, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
await db.delete(holding)
await db.commit()
# ── Investment transactions ────────────────────────────────────────────────
@router.get("/investments/holdings/{holding_id}/transactions", response_model=list[InvestmentTxnResponse])
async def list_transactions(
holding_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await investment_service.list_investment_transactions(db, current_user.id, holding_id)
@router.post("/investments/transactions", response_model=InvestmentTxnResponse, status_code=status.HTTP_201_CREATED)
async def add_transaction(
data: InvestmentTxnCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
txn = await investment_service.add_investment_transaction(db, current_user.id, data)
await db.commit()
return txn
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ── Assets ─────────────────────────────────────────────────────────────────
@router.get("/assets/search", response_model=list[AssetSearch])
async def search_assets(
q: str = Query(..., min_length=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# First search the local DB
local = await investment_service.search_assets(db, q)
if local:
from app.db.models.asset import Asset
return [AssetSearch(
id=a.id, symbol=a.symbol, name=a.name, type=a.type,
currency=a.currency, exchange=a.exchange,
last_price=a.last_price, price_change_24h=a.price_change_24h,
data_source=a.data_source,
) for a in local]
# Fall back to live Yahoo search
import asyncio
loop = asyncio.get_event_loop()
results = await loop.run_in_executor(None, search_yahoo, q)
if not results:
return []
# Upsert into DB so future searches are local
created = []
for r in results:
asset = await investment_service.get_or_create_asset(
db, r["symbol"], r["name"], r["type"],
r["currency"], r["data_source"], r.get("data_source_id"),
r.get("exchange"),
)
created.append(asset)
await db.commit()
return [AssetSearch(
id=a.id, symbol=a.symbol, name=a.name, type=a.type,
currency=a.currency, exchange=a.exchange,
last_price=a.last_price, price_change_24h=a.price_change_24h,
data_source=a.data_source,
) for a in created]
@router.get("/assets/{asset_id}/prices", response_model=list[AssetPricePoint])
async def get_price_history(
asset_id: uuid.UUID,
days: int = Query(default=365, ge=7, le=1825),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.db.models.asset import Asset
from sqlalchemy import select
asset_result = await db.execute(select(Asset).where(Asset.id == asset_id))
asset = asset_result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
# Fetch from DB; if sparse, refresh from Yahoo
prices = await investment_service.get_price_history(db, asset_id, days)
if len(prices) < 5 and asset.data_source == "yahoo_finance":
rows = await fetch_history(asset.symbol, days)
if rows:
await investment_service.upsert_price_history(db, asset_id, rows)
await db.commit()
prices = await investment_service.get_price_history(db, asset_id, days)
return [
AssetPricePoint(
date=p.date, open=p.open, high=p.high, low=p.low,
close=p.close, volume=p.volume,
)
for p in prices
]
@router.post("/assets", response_model=AssetSearch, status_code=status.HTTP_201_CREATED)
async def create_asset(
symbol: str,
name: str,
asset_type: str = "stock",
currency: str = "GBP",
data_source: str = "yahoo_finance",
data_source_id: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
asset = await investment_service.get_or_create_asset(
db, symbol, name, asset_type, currency, data_source, data_source_id
)
await db.commit()
return AssetSearch(
id=asset.id, symbol=asset.symbol, name=asset.name, type=asset.type,
currency=asset.currency, exchange=asset.exchange,
last_price=asset.last_price, price_change_24h=asset.price_change_24h,
data_source=asset.data_source,
)

View file

@ -0,0 +1,236 @@
from __future__ import annotations
from datetime import date
import calendar
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from redis.asyncio import Redis
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rate_limiter import is_rate_limited
from app.dependencies import get_current_user, get_db, get_redis
from app.ml.feature_engineering import (
get_monthly_category_spending,
get_monthly_net_worth,
get_current_month_spending,
get_portfolio_monthly_returns,
get_daily_cash_flow,
)
from app.ml.spending_forecast import forecast_spending
from app.ml.net_worth_projection import project_net_worth
from app.ml.monte_carlo import run_monte_carlo
router = APIRouter(prefix="/predictions", tags=["predictions"])
async def _check_prediction_rate(redis: Redis, user_id: str) -> None:
limited, _ = await is_rate_limited(redis, f"rate:pred:{user_id}", limit=20, window_seconds=60)
if limited:
raise HTTPException(status_code=429, detail="Too many prediction requests — try again shortly")
@router.get("/spending")
async def spending_forecast(
request: Request,
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
user=Depends(get_current_user),
):
await _check_prediction_rate(redis, str(user.id))
df = await get_monthly_category_spending(db, user.id)
categories = forecast_spending(df)
return {"categories": categories}
@router.get("/net-worth")
async def net_worth_projection(
years: int = 5,
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
user=Depends(get_current_user),
):
await _check_prediction_rate(redis, str(user.id))
years = max(1, min(10, years))
df = await get_monthly_net_worth(db, user.id)
result = project_net_worth(df, years=years)
return result
class MonteCarloRequest(BaseModel):
years: int = 5
n_simulations: int = 1000
annual_contribution: float = 0.0
@router.post("/monte-carlo")
async def monte_carlo(
body: MonteCarloRequest,
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
user=Depends(get_current_user),
):
await _check_prediction_rate(redis, str(user.id))
years = max(1, min(10, body.years))
n_sims = max(100, min(5000, body.n_simulations))
# Get portfolio holdings
result = await db.execute(text("""
SELECT h.id, a.symbol, h.quantity::float, a.last_price::float,
(h.quantity * COALESCE(a.last_price, h.avg_cost_basis))::float AS current_value,
h.currency
FROM investment_holdings h
JOIN assets a ON a.id = h.asset_id
WHERE h.user_id = CAST(:uid AS uuid)
AND h.deleted_at IS NULL
AND h.quantity > 0
"""), {"uid": str(user.id)})
holdings = [
{"symbol": r[1], "quantity": r[2], "last_price": r[3], "current_value": r[4]}
for r in result.fetchall()
]
prices_df = await get_portfolio_monthly_returns(db, user.id)
mc = run_monte_carlo(
prices_df=prices_df,
holdings=holdings,
years=years,
n_sims=n_sims,
annual_contribution=body.annual_contribution,
)
return mc
@router.get("/budget-forecast")
async def budget_forecast(
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
user=Depends(get_current_user),
):
await _check_prediction_rate(redis, str(user.id))
today = date.today()
days_in_month = calendar.monthrange(today.year, today.month)[1]
day_of_month = today.day
days_remaining = days_in_month - day_of_month
# Get budgets
bgt_result = await db.execute(text("""
SELECT b.id::text, COALESCE(c.name, 'Uncategorised') AS cat_name,
b.category_id::text, b.amount::float
FROM budgets b
LEFT JOIN categories c ON c.id = b.category_id
WHERE b.user_id = CAST(:uid AS uuid)
AND b.period = 'monthly'
AND (b.end_date IS NULL OR b.end_date >= CURRENT_DATE)
"""), {"uid": str(user.id)})
budgets = {r[2]: {"budget_id": r[0], "category_name": r[1], "amount": r[3]} for r in bgt_result.fetchall()}
if not budgets:
return {"forecasts": [], "message": "No monthly budgets set"}
# Get current month spending per category
spent_df = await get_current_month_spending(db, user.id)
spent_map = {row["category_id"]: row["spent"] for _, row in spent_df.iterrows()}
forecasts = []
for cat_id, bgt in budgets.items():
spent = spent_map.get(cat_id, 0.0)
budget_amt = bgt["amount"]
# Daily velocity
velocity = spent / day_of_month if day_of_month > 0 else 0.0
forecast_total = spent + velocity * days_remaining
# Probability of overspend using a rough normal distribution
# Assume uncertainty grows with days remaining
import math
sigma = velocity * math.sqrt(days_remaining) * 0.3 if velocity > 0 else 1.0
if sigma > 0:
z = (budget_amt - forecast_total) / sigma
# CDF of normal
import scipy.stats
prob_overspend = float(1 - scipy.stats.norm.cdf(z))
else:
prob_overspend = 1.0 if forecast_total > budget_amt else 0.0
forecasts.append({
"category_id": cat_id,
"category_name": bgt["category_name"],
"budget_amount": round(budget_amt, 2),
"spent_so_far": round(spent, 2),
"forecast_month_total": round(max(spent, forecast_total), 2),
"daily_velocity": round(velocity, 2),
"probability_overspend": round(prob_overspend, 3),
"days_remaining": days_remaining,
})
forecasts.sort(key=lambda x: x["probability_overspend"], reverse=True)
return {"forecasts": forecasts}
@router.get("/cashflow")
async def cashflow_forecast(
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
user=Depends(get_current_user),
):
await _check_prediction_rate(redis, str(user.id))
from datetime import timedelta
import numpy as np
# Historical daily cash flows (last 90 days)
hist_df = await get_daily_cash_flow(db, user.id, days=90)
# Get current account balances
acct_result = await db.execute(text("""
SELECT SUM(
CASE WHEN type IN ('credit_card','loan','mortgage') THEN -ABS(current_balance)
ELSE current_balance END
)::float AS total_balance
FROM accounts
WHERE user_id = CAST(:uid AS uuid)
AND is_active = TRUE
AND include_in_net_worth = TRUE
AND deleted_at IS NULL
"""), {"uid": str(user.id)})
row = acct_result.fetchone()
current_balance = float(row[0] or 0.0)
# Compute average daily inflow / outflow from history
if not hist_df.empty:
avg_inflow = float(hist_df["inflow"].mean())
avg_outflow = float(hist_df["outflow"].mean())
std_net = float((hist_df["inflow"] - hist_df["outflow"]).std())
else:
avg_inflow = 0.0
avg_outflow = 0.0
std_net = 0.0
# Project 30 days forward
today = date.today()
daily = []
running_balance = current_balance
for i in range(1, 31):
d = today + timedelta(days=i)
net = avg_inflow - avg_outflow
running_balance += net
daily.append({
"date": d.strftime("%Y-%m-%d"),
"balance": round(running_balance, 2),
"avg_inflow": round(avg_inflow, 2),
"avg_outflow": round(avg_outflow, 2),
"negative_risk": running_balance < 0,
})
negative_days = [d["date"] for d in daily if d["negative_risk"]]
return {
"current_balance": round(current_balance, 2),
"avg_daily_inflow": round(avg_inflow, 2),
"avg_daily_outflow": round(avg_outflow, 2),
"forecast": daily,
"negative_risk_days": negative_days,
"history_days": len(hist_df),
}

View file

@ -0,0 +1,82 @@
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.db.models.user import User
from app.schemas.report import (
BudgetVsActualReport,
CashFlowReport,
CategoryBreakdownReport,
IncomeExpenseReport,
NetWorthReport,
SpendingTrendsReport,
)
from app.services import report_service
router = APIRouter(prefix="/reports", tags=["reports"])
@router.get("/net-worth", response_model=NetWorthReport)
async def net_worth_report(
months: int = Query(default=12, ge=1, le=60),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await report_service.get_net_worth_report(
db, current_user.id, current_user.base_currency, months
)
@router.get("/income-vs-expense", response_model=IncomeExpenseReport)
async def income_expense_report(
months: int = Query(default=12, ge=1, le=60),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await report_service.get_income_expense_report(db, current_user.id, months)
@router.get("/cash-flow", response_model=CashFlowReport)
async def cash_flow_report(
date_from: date = Query(default=None),
date_to: date = Query(default=None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
today = date.today()
df = date_from or (today - timedelta(days=30))
dt = date_to or today
return await report_service.get_cash_flow_report(db, current_user.id, df, dt)
@router.get("/category-breakdown", response_model=CategoryBreakdownReport)
async def category_breakdown(
date_from: date = Query(default=None),
date_to: date = Query(default=None),
type: str = Query(default="expense"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
today = date.today()
df = date_from or date(today.year, today.month, 1)
dt = date_to or today
return await report_service.get_category_breakdown(db, current_user.id, df, dt, type)
@router.get("/budget-vs-actual", response_model=BudgetVsActualReport)
async def budget_vs_actual(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await report_service.get_budget_vs_actual(db, current_user.id)
@router.get("/spending-trends", response_model=SpendingTrendsReport)
async def spending_trends(
months: int = Query(default=6, ge=1, le=24),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await report_service.get_spending_trends(db, current_user.id, months)

View file

@ -0,0 +1,332 @@
import csv
import io
import mimetypes
import os
import uuid
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.audit import write_audit
from app.dependencies import get_current_user, get_db
from app.schemas.transaction import TransactionCreate, TransactionFilter, TransactionUpdate
from app.services.transaction_service import (
TransactionError,
create_transaction,
delete_transaction,
get_transaction,
import_csv,
list_transactions,
update_transaction,
_to_response,
)
MAX_IMPORT_FILE_BYTES = 10 * 1024 * 1024 # 10 MB
MAX_IMPORT_ROWS = 50_000
ALLOWED_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
}
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
router = APIRouter()
@router.get("")
async def get_transactions(
account_id: uuid.UUID | None = None,
category_id: uuid.UUID | None = None,
type: str | None = None,
status: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
search: str | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
from datetime import date
filters = TransactionFilter(
account_id=account_id,
category_id=category_id,
type=type,
status=status,
date_from=date.fromisoformat(date_from) if date_from else None,
date_to=date.fromisoformat(date_to) if date_to else None,
search=search,
page=page,
page_size=page_size,
)
return await list_transactions(db, user.id, filters)
@router.post("", status_code=201)
async def create(
body: TransactionCreate,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
result = await create_transaction(db, user.id, body, user.base_currency)
await write_audit(db, user_id=user.id, action="transaction_create")
await db.commit()
return result
except TransactionError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
@router.get("/{txn_id}")
async def get_one(
txn_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
txn = await get_transaction(db, txn_id, user.id)
return _to_response(txn)
except TransactionError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
@router.put("/{txn_id}")
async def update(
txn_id: uuid.UUID,
body: TransactionUpdate,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
result = await update_transaction(db, txn_id, user.id, body, user.base_currency)
await write_audit(db, user_id=user.id, action="transaction_update", resource_type="transaction", resource_id=txn_id)
await db.commit()
return result
except TransactionError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
@router.delete("/{txn_id}", status_code=204)
async def delete(
txn_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
await delete_transaction(db, txn_id, user.id)
await write_audit(db, user_id=user.id, action="transaction_delete", resource_type="transaction", resource_id=txn_id)
await db.commit()
except TransactionError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
@router.post("/{txn_id}/attachments")
async def upload_attachment(
txn_id: uuid.UUID,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
settings = get_settings()
# Validate extension
filename = file.filename or "upload"
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="Unsupported file type. Allowed: JPG, PNG, WebP, PDF")
# Verify transaction ownership
try:
txn = await get_transaction(db, txn_id, user.id)
except TransactionError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
current_refs: list = txn.get("attachment_refs", []) if isinstance(txn, dict) else []
# Fetch raw model for JSONB mutation
from sqlalchemy import select
from app.db.models.transaction import Transaction as TxnModel
result = await db.execute(
select(TxnModel).where(TxnModel.id == txn_id, TxnModel.user_id == user.id)
)
txn_row = result.scalar_one_or_none()
if not txn_row:
raise HTTPException(status_code=404, detail="Transaction not found")
current_refs = list(txn_row.attachment_refs or [])
if len(current_refs) >= settings.max_attachments_per_txn:
raise HTTPException(status_code=400, detail=f"Maximum {settings.max_attachments_per_txn} attachments per transaction")
# Read and size-check
content = await file.read(settings.max_attachment_bytes + 1)
if len(content) > settings.max_attachment_bytes:
raise HTTPException(status_code=413, detail="File too large (max 10 MB)")
# Sniff MIME from content
import magic # python-magic
detected_mime = magic.from_buffer(content[:2048], mime=True)
if detected_mime not in ALLOWED_MIME_TYPES:
raise HTTPException(status_code=400, detail="File content does not match an allowed type (JPEG, PNG, WebP, PDF)")
# Store file
attachment_id = str(uuid.uuid4())
user_upload_dir = Path(settings.upload_dir) / str(user.id)
user_upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = f"{attachment_id}{ext}"
stored_path = user_upload_dir / stored_name
stored_path.write_bytes(content)
# Update attachment_refs
ref = {
"id": attachment_id,
"filename": filename,
"mime_type": detected_mime,
"size": len(content),
"stored_name": stored_name,
}
from sqlalchemy import update as sql_update
import copy
new_refs = copy.copy(current_refs)
new_refs.append(ref)
await db.execute(
sql_update(TxnModel)
.where(TxnModel.id == txn_id)
.values(attachment_refs=new_refs)
)
await write_audit(db, user_id=user.id, action="transaction_update", resource_type="transaction", resource_id=txn_id, metadata={"attachment_added": attachment_id})
await db.commit()
return ref
@router.get("/{txn_id}/attachments/{attachment_id}")
async def download_attachment(
txn_id: uuid.UUID,
attachment_id: str,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
settings = get_settings()
from sqlalchemy import select
from app.db.models.transaction import Transaction as TxnModel
result = await db.execute(
select(TxnModel).where(TxnModel.id == txn_id, TxnModel.user_id == user.id)
)
txn_row = result.scalar_one_or_none()
if not txn_row:
raise HTTPException(status_code=404, detail="Transaction not found")
ref = next((r for r in (txn_row.attachment_refs or []) if r["id"] == attachment_id), None)
if not ref:
raise HTTPException(status_code=404, detail="Attachment not found")
path = Path(settings.upload_dir) / str(user.id) / ref["stored_name"]
if not path.exists():
raise HTTPException(status_code=404, detail="Attachment file missing")
return FileResponse(
path=str(path),
media_type=ref["mime_type"],
filename=ref["filename"],
)
@router.delete("/{txn_id}/attachments/{attachment_id}", status_code=204)
async def delete_attachment(
txn_id: uuid.UUID,
attachment_id: str,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
settings = get_settings()
from sqlalchemy import select, update as sql_update
from app.db.models.transaction import Transaction as TxnModel
result = await db.execute(
select(TxnModel).where(TxnModel.id == txn_id, TxnModel.user_id == user.id)
)
txn_row = result.scalar_one_or_none()
if not txn_row:
raise HTTPException(status_code=404, detail="Transaction not found")
refs = list(txn_row.attachment_refs or [])
ref = next((r for r in refs if r["id"] == attachment_id), None)
if not ref:
raise HTTPException(status_code=404, detail="Attachment not found")
# Delete file
path = Path(settings.upload_dir) / str(user.id) / ref["stored_name"]
try:
path.unlink(missing_ok=True)
except OSError:
pass
new_refs = [r for r in refs if r["id"] != attachment_id]
await db.execute(
sql_update(TxnModel)
.where(TxnModel.id == txn_id)
.values(attachment_refs=new_refs)
)
await write_audit(db, user_id=user.id, action="transaction_update", resource_type="transaction", resource_id=txn_id, metadata={"attachment_deleted": attachment_id})
await db.commit()
@router.post("/import")
async def import_transactions(
file: UploadFile = File(...),
account_id: uuid.UUID = Form(...),
date_col: str = Form(default="date"),
description_col: str = Form(default="description"),
amount_col: str = Form(default="amount"),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
if not file.filename or not file.filename.lower().endswith(".csv"):
raise HTTPException(status_code=400, detail="Only CSV files are supported")
content = await file.read(MAX_IMPORT_FILE_BYTES + 1)
if len(content) > MAX_IMPORT_FILE_BYTES:
raise HTTPException(status_code=413, detail="File too large (max 10 MB)")
try:
text = content.decode("utf-8-sig") # handle BOM
except UnicodeDecodeError:
text = content.decode("latin-1")
reader = csv.DictReader(io.StringIO(text))
rows = []
for row in reader:
if len(rows) >= MAX_IMPORT_ROWS:
raise HTTPException(status_code=400, detail=f"File contains too many rows (max {MAX_IMPORT_ROWS:,})")
mapped = {}
# Flexible column mapping
for key, col in [("date", date_col), ("description", description_col), ("amount", amount_col)]:
val = row.get(col) or row.get(col.lower()) or row.get(col.upper())
if val is not None:
mapped[key] = val.strip()
if "date" in mapped and "amount" in mapped:
mapped.setdefault("description", "Imported transaction")
rows.append(mapped)
if not rows:
raise HTTPException(status_code=400, detail="No valid rows found. Check column names.")
result = await import_csv(db, user.id, account_id, rows, user.base_currency)
await write_audit(db, user_id=user.id, action="import_data", metadata=result)
await db.commit()
return result
@router.get("/import/template")
async def import_template():
from fastapi.responses import Response
csv_content = "date,description,amount,merchant,notes\n2026-01-15,Tesco Groceries,-45.67,Tesco,\n2026-01-14,Salary,2500.00,Employer,January salary\n"
return Response(
content=csv_content,
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=import_template.csv"},
)

126
backend/app/api/v1/users.py Normal file
View file

@ -0,0 +1,126 @@
import csv
import io
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import write_audit
from app.core.security import hash_password, verify_password
from app.dependencies import get_current_user, get_db
router = APIRouter()
@router.get("/me")
async def get_me(user=Depends(get_current_user)):
return {
"id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"base_currency": user.base_currency,
"theme": user.theme,
"locale": user.locale,
"totp_enabled": user.totp_enabled,
"last_login_at": user.last_login_at,
"created_at": user.created_at,
}
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str = Field(..., min_length=10)
@router.post("/me/password", status_code=200)
async def change_password(
body: PasswordChangeRequest,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
user.password_hash = hash_password(body.new_password)
user.updated_at = datetime.now(timezone.utc)
await write_audit(db, user_id=user.id, action="password_change")
await db.commit()
return {"message": "Password updated successfully"}
class ProfileUpdateRequest(BaseModel):
display_name: str | None = Field(default=None, max_length=100)
base_currency: str | None = Field(default=None, min_length=3, max_length=10)
@router.put("/me", status_code=200)
async def update_profile(
body: ProfileUpdateRequest,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
if body.display_name is not None:
user.display_name = body.display_name
if body.base_currency is not None:
user.base_currency = body.base_currency.upper()
user.updated_at = datetime.now(timezone.utc)
await db.commit()
return {"message": "Profile updated"}
@router.get("/me/export")
async def export_data(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
from app.db.models.transaction import Transaction
from app.db.models.account import Account
from app.db.models.category import Category
from app.core.security import decrypt_field
result = await db.execute(
select(Transaction, Account, Category)
.join(Account, Account.id == Transaction.account_id)
.outerjoin(Category, Category.id == Transaction.category_id)
.where(
Transaction.user_id == user.id,
Transaction.deleted_at.is_(None),
)
.order_by(Transaction.date.desc())
)
rows = result.all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"date", "description", "merchant", "amount", "currency",
"type", "status", "category", "account", "notes", "tags",
])
for txn, account, category in rows:
writer.writerow([
txn.date.isoformat(),
decrypt_field(txn.description_enc) or "",
decrypt_field(txn.merchant_enc) if txn.merchant_enc else "",
str(txn.amount),
txn.currency,
txn.type,
txn.status,
category.name if category else "",
decrypt_field(account.name_enc) or "",
decrypt_field(txn.notes_enc) if txn.notes_enc else "",
",".join(txn.tags or []),
])
output.seek(0)
filename = f"transactions_{datetime.now(timezone.utc).strftime('%Y%m%d')}.csv"
await write_audit(db, user_id=user.id, action="data_export")
await db.commit()
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)