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/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
236
backend/app/api/v1/accounts.py
Normal file
236
backend/app/api/v1/accounts.py
Normal 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
342
backend/app/api/v1/auth.py
Normal 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"}
|
||||
79
backend/app/api/v1/budgets.py
Normal file
79
backend/app/api/v1/budgets.py
Normal 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()
|
||||
36
backend/app/api/v1/categories.py
Normal file
36
backend/app/api/v1/categories.py
Normal 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
|
||||
199
backend/app/api/v1/investments.py
Normal file
199
backend/app/api/v1/investments.py
Normal 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,
|
||||
)
|
||||
236
backend/app/api/v1/predictions.py
Normal file
236
backend/app/api/v1/predictions.py
Normal 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),
|
||||
}
|
||||
82
backend/app/api/v1/reports.py
Normal file
82
backend/app/api/v1/reports.py
Normal 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)
|
||||
332
backend/app/api/v1/transactions.py
Normal file
332
backend/app/api/v1/transactions.py
Normal 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
126
backend/app/api/v1/users.py
Normal 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}"},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue