diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a7edd5d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**MyMidas** — a self-hosted personal finance tracker. Full-stack: FastAPI backend + React/TypeScript frontend, running in Docker Compose with PostgreSQL and Redis. + +--- + +## Commands + +### Backend (Python 3.12 + FastAPI) +```bash +# Dev server (from backend/) +python -m uvicorn app.main:app --reload --port 8080 + +# Apply migrations +python -m alembic upgrade head + +# Create a new migration +python -m alembic revision --autogenerate -m "description" + +# Run tests +pytest tests/ +pytest tests/test_foo.py::test_bar # single test +``` + +### Frontend (React 18 + Vite) +```bash +cd frontend +npm run dev # Vite dev server on :5173, proxies /api → localhost:8080 +npm run build # TypeScript check + production build +npm run lint # ESLint +``` + +### Docker (production) +```bash +docker compose up -d --build # Full rebuild and start +docker compose up -d --build backend # Rebuild backend only +docker compose logs -f backend # Follow backend logs +``` + +--- + +## Architecture + +### Request flow +``` +Browser → Frontend nginx (:4000) or Vite dev (:5173) + → /api/* proxied to FastAPI (:8000 in Docker, :8080 in dev) + → Middleware: SecurityHeaders → CSRF → CORSMiddleware + → Rate limiter (Redis sliding window) + → Route handler → get_current_user dependency + → Service layer (business logic + encryption) + → AsyncSession → PostgreSQL (RLS enforced) +``` + +### Auth architecture +- **JWT**: RS256, 15-min access tokens + 7-day HttpOnly refresh cookie +- `get_current_user` dependency validates the JWT, checks session exists and isn't revoked in DB, then calls `SET LOCAL app.current_user_id` to activate PostgreSQL RLS +- **TOTP**: optional TOTP on login; before TOTP is verified, a short-lived `challenge_token` is issued +- **CSRF**: double-submit cookie — backend sets `csrf_token` cookie on first GET; frontend reads it and sends `X-CSRF-Token` header on all mutating requests + +### Backend layout +``` +backend/app/ + main.py — FastAPI app factory (middleware stack, router include) + config.py — Pydantic Settings from env vars + dependencies.py — get_db, get_redis, get_current_user + api/ + router.py — Central router; investments/reports/budgets have no prefix (paths self-contained) + v1/ — One file per domain (auth, accounts, transactions, budgets, reports, investments, predictions) + db/models/ — SQLAlchemy 2.0 Mapped models + schemas/ — Pydantic request/response models (separate Create/Update/Response per domain) + services/ — Business logic; each service owns one domain + core/ + security.py — Crypto primitives: Argon2id, RS256 JWT, AES-256-GCM, TOTP + middleware.py — CSRFMiddleware, SecurityHeadersMiddleware + workers/ + scheduler.py — APScheduler in-process jobs + ml/ — Prophet/SARIMA forecasts, Monte Carlo simulations +``` + +### Service layer conventions +- All DB ops use `AsyncSession` + `await` +- PII fields (account name, transaction description/merchant/notes, TOTP secret) are AES-256-GCM encrypted; stored as `bytea` named with `_enc` suffix (e.g. `description_enc`). Use `encrypt_field` / `decrypt_field` from `core/security.py` +- Import deduplication via SHA-256 `import_hash` on transactions +- Every mutation writes to `AuditLog` (append-only; app role has no UPDATE/DELETE on that table) +- Soft deletes: `deleted_at` timestamp; all queries must filter `WHERE deleted_at IS NULL` + +### Frontend layout +``` +frontend/src/ + api/ — Typed axios clients per domain; all use the shared client in api/client.ts + pages/ — Route-level components (one directory per domain) + components/ — Shared UI (layout, charts, modals) + store/ — Zustand: authStore (token/userId/displayName), uiStore (theme/sidebar/currency) + utils/ — Currency formatting, cn() class helper +``` + +### Frontend data fetching +TanStack Query (React Query v5) for all server state. `queryKey` conventions determine cache invalidation — always invalidate the correct key after mutations. The axios client in `api/client.ts` handles token injection, CSRF header, and 401 auto-refresh transparently. + +### Background jobs (APScheduler, in-process) +- **Every 15 min**: sync asset prices (yfinance + CoinGecko) +- **Every 60 min**: sync FX rates +- **2 AM daily**: net worth snapshot +- **3 AM daily**: encrypted GPG backup +- **Sunday weekly**: retrain ML prediction models + +### Database patterns +- UUID PKs everywhere +- PostgreSQL RLS policies on every table keyed to `app.current_user_id` (set per-request by `get_current_user`) +- `postgres/init/` contains init SQL; alembic manages schema evolution +- Migrations run automatically on container start (`alembic upgrade head` in Dockerfile CMD) + +### Themes +7 CSS variable themes in `frontend/src/index.css`: obsidian (default dark), arctic (light), midnight, vault, terminal, synthwave, ledger. Applied as a class on ``. + +--- + +## Environment variables +See `.env.example` for the full list. Key ones: +- `ENCRYPTION_KEY` — 32-byte base64 for AES-256-GCM field encryption +- `DATABASE_URL` — asyncpg connection string +- `ENVIRONMENT` — `development` enables `/docs`, `/redoc`, `/openapi.json` and relaxes CORS +- `ALLOW_REGISTRATION` — gates the register endpoint (default `false` in prod) +- `BASE_CURRENCY` — default display currency (default `GBP`) diff --git a/README.md b/README.md index 4cef08e..24cd41b 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Ten independent security layers: 3. **Security headers** — CSP, HSTS, X-Frame-Options DENY, X-Content-Type-Options, form-action, Permissions-Policy 4. **CSRF** — double-submit cookie on all mutating endpoints 5. **Authentication** — Argon2id password hashing; RS256 JWT (15-min access + 7-day HttpOnly refresh cookie); TOTP (RFC 6238) with backup codes; brute-force lockout (exponential backoff via Redis) -6. **Rate limiting** — Redis sliding window: 10/min on auth, 10/min on TOTP, 20/min on predictions, 300/min general API +6. **Rate limiting** — Redis sliding window: 20/min on login (per IP), 10/min on TOTP, 20/min on predictions, 300/min general API 7. **Field-level encryption** — AES-256-GCM on all PII fields (account names, transaction descriptions, merchant, notes, TOTP secret); IV ‖ ciphertext ‖ tag stored in `bytea` 8. **Database encryption** — pgcrypto as a second layer on backup dumps 9. **Row-level security** — PostgreSQL RLS policies enforce user isolation at the DB layer; buggy queries cannot leak another user's data @@ -168,44 +168,62 @@ docker compose up -d backend ### 5. Point your reverse proxy -Forward your domain to `http://:8090`. The frontend is served from port `4000` internally but the backend proxies frontend assets — point everything at `8090`. +Forward your domain to `http://:4000`. The frontend nginx serves the React app and proxies all `/api/` requests to the backend internally. --- ## Backups -Encrypted nightly backup runs automatically at 3 AM: +Encrypted backups run automatically every night at 3 AM (GPG AES-256 symmetric encryption). Backups are stored in `./data/backups/` and retained for 30 days. + +**Via the web UI:** Settings → Backups — trigger a manual backup, download a copy, or restore from any listed backup file. + +**Manual backup from the command line:** ```bash -# Manual backup -docker compose exec -e BACKUP_PASSPHRASE="$BACKUP_PASSPHRASE" backend bash scripts/backup.sh - -# List backups -docker compose exec backend bash scripts/restore.sh --list - -# Restore -docker compose exec -e BACKUP_PASSPHRASE="$BACKUP_PASSPHRASE" \ - -e DATABASE_URL="$DATABASE_URL" \ - backend bash scripts/restore.sh 20240101_030000.sql.gz.gpg +docker compose exec backend bash /app/scripts/backup.sh ``` -Backups are stored in `./data/backups/` and retained for 30 days. +**Manual restore** (stop the backend first to avoid lock contention): + +```bash +docker compose stop backend + +docker compose run --rm backend bash -c ' + export GNUPGHOME=/tmp/.gnupg; mkdir -p $GNUPGHOME && chmod 700 $GNUPGHOME + PG_URL=${DATABASE_URL/postgresql+asyncpg/postgresql} + gpg --batch --yes --no-symkey-cache --pinentry-mode loopback \ + --passphrase "$BACKUP_PASSPHRASE" \ + --decrypt $(ls /app/backups/*.sql.gz.gpg | tail -1) \ + | gunzip | psql "$PG_URL" +' + +docker compose start backend +``` ## Key Rotation To rotate the AES-256-GCM encryption key without data loss: ```bash -# Generate new key +# Generate a new key NEW_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))") +echo "New key: $NEW_KEY" -# Stop the app, rotate, update .env, restart -docker compose stop backend -NEW_ENCRYPTION_KEY="$NEW_KEY" ./scripts/rotate_keys.sh -# Update ENCRYPTION_KEY in .env to $NEW_KEY +# Dry-run first — validates decryption works, no DB changes +docker compose exec backend python /app/scripts/rotate_keys.py \ + --old-key "$ENCRYPTION_KEY" --new-key "$NEW_KEY" --dry-run + +# If dry-run passes, rotate for real (atomic — rolls back on any failure) +docker compose exec backend python /app/scripts/rotate_keys.py \ + --old-key "$ENCRYPTION_KEY" --new-key "$NEW_KEY" + +# Update ENCRYPTION_KEY in .env, then restart docker compose up -d backend ``` +Do not lose your current `ENCRYPTION_KEY` — without it, all encrypted fields are permanently unreadable. + --- ## Environment Variables @@ -242,7 +260,7 @@ MyMidas/ │ ├── pages/ # Route-level page components │ └── store/ # Zustand state (auth, UI/theme) ├── postgres/init/ # PostgreSQL init SQL (extensions, RLS policies) -├── scripts/ # backup.sh, restore.sh, rotate_keys.sh +├── backend/scripts/ # backup.sh, rotate_keys.py, entrypoint.sh ├── secrets/ # JWT keys (git-ignored, generate locally) └── docker-compose.yml ``` diff --git a/backend/Dockerfile b/backend/Dockerfile index d9f6681..ff79601 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,10 @@ FROM python:3.12-slim AS base RUN apt-get update && apt-get install -y --no-install-recommends \ libmagic1 \ + postgresql-client \ + gnupg \ + gzip \ + gosu \ && rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir uv WORKDIR /app @@ -13,7 +17,13 @@ FROM deps AS production COPY app/ ./app/ COPY alembic/ ./alembic/ COPY alembic.ini ./ -RUN useradd -r -s /bin/false -u 1001 appuser && chown -R appuser /app && mkdir -p /app/uploads && chown appuser /app/uploads -USER appuser +COPY scripts/ ./scripts/ +RUN useradd -r -s /bin/false -u 1001 appuser \ + && chown -R appuser /app \ + && mkdir -p /app/uploads /app/backups \ + && chown appuser /app/uploads /app/backups +COPY scripts/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh EXPOSE 8000 +ENTRYPOINT ["/entrypoint.sh"] CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 --proxy-headers"] diff --git a/backend/alembic/versions/0002_refresh_token_hash.py b/backend/alembic/versions/0002_refresh_token_hash.py new file mode 100644 index 0000000..4c74799 --- /dev/null +++ b/backend/alembic/versions/0002_refresh_token_hash.py @@ -0,0 +1,23 @@ +"""add refresh_token_hash to sessions + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-04-22 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("sessions", sa.Column("refresh_token_hash", sa.Text, nullable=True)) + op.create_index("ix_sessions_refresh_token_hash", "sessions", ["refresh_token_hash"]) + + +def downgrade() -> None: + op.drop_index("ix_sessions_refresh_token_hash", table_name="sessions") + op.drop_column("sessions", "refresh_token_hash") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index a22bbf1..eed071f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions +from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin router = APIRouter() router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -12,3 +12,4 @@ router.include_router(budgets.router) router.include_router(reports.router) router.include_router(investments.router) router.include_router(predictions.router) +router.include_router(admin.router) diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py new file mode 100644 index 0000000..9475c90 --- /dev/null +++ b/backend/app/api/v1/admin.py @@ -0,0 +1,152 @@ +import asyncio +import os +import re +from datetime import datetime +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from app.dependencies import get_current_user +from app.db.models.user import User + +router = APIRouter(prefix="/admin", tags=["admin"]) + +BACKUP_DIR = Path(os.environ.get("BACKUP_DIR", "/app/backups")) +BACKUP_PATTERN = re.compile(r"^\d{8}_\d{6}\.sql\.gz\.gpg$") + + +class BackupFile(BaseModel): + filename: str + size_bytes: int + created_at: str + + +class BackupResult(BaseModel): + ok: bool + message: str + + +def _list_backup_files() -> list[BackupFile]: + if not BACKUP_DIR.exists(): + return [] + files = [] + for f in sorted(BACKUP_DIR.glob("*.sql.gz.gpg"), reverse=True): + stat = f.stat() + files.append(BackupFile( + filename=f.name, + size_bytes=stat.st_size, + created_at=datetime.fromtimestamp(stat.st_mtime).isoformat(), + )) + return files + + +@router.get("/backups", response_model=list[BackupFile]) +async def list_backups(current_user: User = Depends(get_current_user)): + return _list_backup_files() + + +@router.post("/backup", response_model=BackupResult) +async def trigger_backup(current_user: User = Depends(get_current_user)): + try: + proc = await asyncio.create_subprocess_exec( + "bash", "/app/scripts/backup.sh", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + stdout, _ = await proc.communicate() + output = stdout.decode().strip() if stdout else "" + if proc.returncode == 0: + return BackupResult(ok=True, message=output or "Backup completed") + raise HTTPException(status_code=500, detail=f"Backup failed: {output}") + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.get("/backups/{filename}") +async def download_backup( + filename: str, + current_user: User = Depends(get_current_user), +): + if not BACKUP_PATTERN.match(filename): + raise HTTPException(status_code=400, detail="Invalid filename") + path = BACKUP_DIR / filename + if not path.exists(): + raise HTTPException(status_code=404, detail="Backup not found") + return FileResponse( + path=str(path), + filename=filename, + media_type="application/octet-stream", + ) + + +@router.post("/restore/{filename}", response_model=BackupResult) +async def restore_backup( + filename: str, + current_user: User = Depends(get_current_user), +): + if not BACKUP_PATTERN.match(filename): + raise HTTPException(status_code=400, detail="Invalid filename") + path = BACKUP_DIR / filename + if not path.exists(): + raise HTTPException(status_code=404, detail="Backup not found") + + passphrase = os.environ.get("BACKUP_PASSPHRASE", "") + if not passphrase: + raise HTTPException(status_code=500, detail="BACKUP_PASSPHRASE not configured") + + database_url = os.environ.get("DATABASE_URL", "") + pg_url = database_url.replace("postgresql+asyncpg", "postgresql") + + # Ensure GPG has a writable home (appuser has no real home directory) + gnupg_home = "/tmp/.gnupg" + os.makedirs(gnupg_home, mode=0o700, exist_ok=True) + gpg_env = {**os.environ, "GNUPGHOME": gnupg_home} + + try: + # Decrypt and decompress into psql non-interactively + gpg_proc = await asyncio.create_subprocess_exec( + "gpg", "--batch", "--yes", "--no-symkey-cache", + "--pinentry-mode", "loopback", + "--decrypt", "--passphrase", passphrase, + str(path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=gpg_env, + ) + gpg_out, gpg_err = await gpg_proc.communicate() + if gpg_proc.returncode != 0: + raise HTTPException(status_code=500, detail=f"Decryption failed: {gpg_err.decode()}") + + gunzip_proc = await asyncio.create_subprocess_exec( + "gunzip", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + sql_data, gunzip_err = await gunzip_proc.communicate(input=gpg_out) + if gunzip_proc.returncode != 0: + raise HTTPException(status_code=500, detail=f"Decompression failed: {gunzip_err.decode()}") + + psql_proc = await asyncio.create_subprocess_exec( + "psql", + "--single-transaction", + "-v", "ON_ERROR_STOP=1", + pg_url, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + psql_out, psql_err = await psql_proc.communicate(input=sql_data) + if psql_proc.returncode != 0: + detail = (psql_err.decode().strip() or psql_out.decode().strip() or "psql exited with no output") + raise HTTPException(status_code=500, detail=f"Restore failed: {detail}") + + return BackupResult(ok=True, message=f"Restored from {filename}") + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index bddf280..c6bd973 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -10,6 +10,7 @@ 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.schemas.auth import TOTPEnableRequest from app.dependencies import get_current_user, get_db, get_redis from app.schemas.auth import ( LoginRequest, @@ -96,6 +97,11 @@ async def login( db: AsyncSession = Depends(get_db), redis: Redis = Depends(get_redis), ): + ip = _ip(request) or "unknown" + limited, _ = await is_rate_limited(redis, f"rate:login:{ip}", limit=20, window_seconds=60) + if limited: + raise HTTPException(status_code=429, detail="Too many login attempts — try again shortly") + try: user, access_token, refresh_token = await authenticate_user( db, redis, body.email, body.password, _ip(request), _ua(request) @@ -171,24 +177,27 @@ async def refresh_token( user_id = uuid.UUID(payload["sub"]) now = datetime.now(timezone.utc) + refresh_hash = hash_token(token) - # Find and update session + # Find the specific session this refresh token was issued for result = await db.execute( select(Session).where( Session.user_id == user_id, + Session.refresh_token_hash == refresh_hash, 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") + raise HTTPException(status_code=401, detail="Session not found or refresh token already used") new_access = create_access_token(str(user_id)) new_refresh = create_refresh_token(str(user_id)) - # Rotate session token hash + # Rotate both token hashes — old refresh token is now invalid session.token_hash = hash_token(new_access) + session.refresh_token_hash = hash_token(new_refresh) session.last_active_at = now await db.commit() @@ -305,17 +314,13 @@ async def totp_verify( @router.post("/totp/enable", status_code=200) async def totp_enable( - body: dict, + body: TOTPEnableRequest, 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) + await enable_totp(user, db, body.secret, body.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)) diff --git a/backend/app/api/v1/investments.py b/backend/app/api/v1/investments.py index b917dfb..9866be2 100644 --- a/backend/app/api/v1/investments.py +++ b/backend/app/api/v1/investments.py @@ -1,7 +1,9 @@ import uuid from datetime import date +from decimal import Decimal -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +from sqlalchemy import select, delete as sa_delete from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db @@ -9,6 +11,7 @@ from app.db.models.user import User from app.schemas.investment import ( AssetSearch, AssetPricePoint, + CapitalGainsReport, HoldingCreate, HoldingResponse, InvestmentTxnCreate, @@ -29,7 +32,7 @@ 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) + return await investment_service.get_portfolio(db, current_user.id, current_user.base_currency) @router.get("/investments/performance", response_model=PerformanceMetrics) @@ -37,7 +40,15 @@ 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) + return await investment_service.get_performance(db, current_user.id, current_user.base_currency) + + +@router.get("/investments/capital-gains", response_model=CapitalGainsReport) +async def get_capital_gains( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return await investment_service.get_capital_gains(db, current_user.id, current_user.base_currency) # ── Holdings ─────────────────────────────────────────────────────────────── @@ -61,6 +72,27 @@ async def create_holding( return investment_service._holding_to_response(holding, asset) +@router.patch("/investments/holdings/{holding_id}", response_model=HoldingResponse) +async def update_holding( + holding_id: uuid.UUID, + quantity: Decimal = Body(...), + avg_cost_basis: Decimal = Body(...), + 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") + holding.quantity = quantity + holding.avg_cost_basis = avg_cost_basis + await db.commit() + await db.refresh(holding) + from app.db.models.asset import Asset + result = await db.execute(select(Asset).where(Asset.id == holding.asset_id)) + asset = result.scalar_one_or_none() + 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, @@ -70,6 +102,8 @@ async def delete_holding( holding = await investment_service.get_holding(db, current_user.id, holding_id) if not holding: raise HTTPException(status_code=404, detail="Holding not found") + from app.db.models.investment_transaction import InvestmentTransaction + await db.execute(sa_delete(InvestmentTransaction).where(InvestmentTransaction.holding_id == holding_id)) await db.delete(holding) await db.commit() diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py index 1fbf0e8..23d82a6 100644 --- a/backend/app/api/v1/reports.py +++ b/backend/app/api/v1/reports.py @@ -12,6 +12,7 @@ from app.schemas.report import ( CategoryBreakdownReport, IncomeExpenseReport, NetWorthReport, + SavingsRateReport, SpendingTrendsReport, ) from app.services import report_service @@ -83,6 +84,15 @@ async def spending_trends( return await report_service.get_spending_trends(db, current_user.id, months) +@router.get("/savings-rate", response_model=SavingsRateReport) +async def savings_rate( + 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_savings_rate_report(db, current_user.id, months) + + @router.get("/balance-sheet", response_model=BalanceSheetReport) async def balance_sheet( db: AsyncSession = Depends(get_db), diff --git a/backend/app/api/v1/transactions.py b/backend/app/api/v1/transactions.py index 7c2512b..7345b5f 100644 --- a/backend/app/api/v1/transactions.py +++ b/backend/app/api/v1/transactions.py @@ -48,6 +48,7 @@ async def get_transactions( date_from: str | None = None, date_to: str | None = None, search: str | None = None, + is_recurring: bool | None = None, page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), db: AsyncSession = Depends(get_db), @@ -62,6 +63,7 @@ async def get_transactions( date_from=date.fromisoformat(date_from) if date_from else None, date_to=date.fromisoformat(date_to) if date_to else None, search=search, + is_recurring=is_recurring, page=page, page_size=page_size, ) diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index d38c7aa..7e80cb4 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -7,6 +7,8 @@ from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse +from app.config import get_settings + SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} SECURITY_HEADERS = { @@ -55,7 +57,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): "csrf_token", token, httponly=False, # must be readable by JS samesite="lax", - secure=False, # set True if TLS is terminated at this service + secure=not get_settings().is_development, ) return response @@ -63,7 +65,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): response = await call_next(request) if not existing_csrf: token = str(uuid.uuid4()) - response.set_cookie("csrf_token", token, httponly=False, samesite="lax", secure=False) + response.set_cookie("csrf_token", token, httponly=False, samesite="lax", secure=not get_settings().is_development) return response if request.url.path in {"/api/v1/auth/login", "/api/v1/auth/login/totp"}: diff --git a/backend/app/db/models/session.py b/backend/app/db/models/session.py index 2a2ef7b..a8c39db 100644 --- a/backend/app/db/models/session.py +++ b/backend/app/db/models/session.py @@ -14,6 +14,7 @@ class Session(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False, index=True) + refresh_token_hash: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) ip_address: Mapped[str | None] = mapped_column(INET, nullable=True) user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) last_active_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 0d3d2f2..0380f25 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -52,6 +52,11 @@ class TOTPVerifyRequest(BaseModel): code: str +class TOTPEnableRequest(BaseModel): + secret: str + code: str + + class SessionInfo(BaseModel): id: uuid.UUID ip_address: str | None diff --git a/backend/app/schemas/investment.py b/backend/app/schemas/investment.py index b237780..e4d0e07 100644 --- a/backend/app/schemas/investment.py +++ b/backend/app/schemas/investment.py @@ -101,3 +101,28 @@ class PerformanceMetrics(BaseModel): total_return: Decimal total_return_pct: Decimal currency: str + + +class CapitalGainsDisposal(BaseModel): + date: DateType + symbol: str + asset_name: str + quantity: Decimal + proceeds: Decimal + cost: Decimal + gain: Decimal + currency: str + + +class TaxYearSummary(BaseModel): + tax_year: str + disposals: list[CapitalGainsDisposal] + total_proceeds: Decimal + total_cost: Decimal + total_gain: Decimal + currency: str + + +class CapitalGainsReport(BaseModel): + tax_years: list[TaxYearSummary] + currency: str diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py index 0b97d4a..6e07abb 100644 --- a/backend/app/schemas/report.py +++ b/backend/app/schemas/report.py @@ -96,6 +96,20 @@ class SpendingTrendsReport(BaseModel): currency: str +class SavingsRatePoint(BaseModel): + month: str + income: Decimal + expenses: Decimal + savings: Decimal + savings_rate: Decimal + + +class SavingsRateReport(BaseModel): + points: list[SavingsRatePoint] + avg_savings_rate: Decimal + currency: str + + class BalanceSheetAccount(BaseModel): id: str name: str diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py index 870f5c5..5d10087 100644 --- a/backend/app/schemas/transaction.py +++ b/backend/app/schemas/transaction.py @@ -48,6 +48,7 @@ class TransactionFilter(BaseModel): max_amount: Decimal | None = None search: str | None = None tags: list[str] = [] + is_recurring: bool | None = None page: int = Field(default=1, ge=1) page_size: int = Field(default=50, ge=1, le=200) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index b759f63..269acfe 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -137,6 +137,7 @@ async def _create_session( session = Session( user_id=user.id, token_hash=hash_token(access_token), + refresh_token_hash=hash_token(refresh_token), ip_address=ip, user_agent=user_agent, last_active_at=now, diff --git a/backend/app/services/investment_service.py b/backend/app/services/investment_service.py index f6e4acf..7d3edab 100644 --- a/backend/app/services/investment_service.py +++ b/backend/app/services/investment_service.py @@ -10,11 +10,14 @@ from app.db.models.asset_price import AssetPrice from app.db.models.investment_holding import InvestmentHolding from app.db.models.investment_transaction import InvestmentTransaction from app.schemas.investment import ( + CapitalGainsDisposal, + CapitalGainsReport, HoldingCreate, HoldingResponse, InvestmentTxnCreate, PerformanceMetrics, PortfolioSummary, + TaxYearSummary, ) @@ -23,10 +26,37 @@ async def _get_asset(db: AsyncSession, asset_id: uuid.UUID) -> Asset | None: return result.scalar_one_or_none() -def _holding_to_response(holding: InvestmentHolding, asset: Asset) -> HoldingResponse: +async def _fetch_fx_rate(db: AsyncSession, from_currency: str, to_currency: str) -> Decimal: + if from_currency == to_currency: + return Decimal("1") + from app.db.models.currency import ExchangeRate + result = await db.execute( + select(ExchangeRate) + .where(ExchangeRate.base_currency == from_currency, ExchangeRate.quote_currency == to_currency) + .order_by(ExchangeRate.fetched_at.desc()) + .limit(1) + ) + er = result.scalar_one_or_none() + return er.rate if er else Decimal("1") + + +def _holding_to_response( + holding: InvestmentHolding, + asset: Asset, + fx_rates: dict[tuple[str, str], Decimal] | None = None, +) -> HoldingResponse: + fx_rates = fx_rates or {} cost_basis_total = holding.quantity * holding.avg_cost_basis - current_price = asset.last_price - current_value = holding.quantity * current_price if current_price else None + + # Convert asset's last_price to the holding's currency so P&L is comparable + current_price_native = asset.last_price + if current_price_native is not None and asset.currency != holding.currency: + rate = fx_rates.get((asset.currency, holding.currency), Decimal("1")) + current_price = current_price_native * rate + else: + current_price = current_price_native + + current_value = holding.quantity * current_price if current_price is not None else None unrealised_gain = (current_value - cost_basis_total) if current_value is not None else None unrealised_gain_pct = None if unrealised_gain is not None and cost_basis_total > 0: @@ -51,7 +81,7 @@ def _holding_to_response(holding: InvestmentHolding, asset: Asset) -> HoldingRes ) -async def get_portfolio(db: AsyncSession, user_id: uuid.UUID) -> PortfolioSummary: +async def get_portfolio(db: AsyncSession, user_id: uuid.UUID, base_currency: str = "GBP") -> PortfolioSummary: result = await db.execute( select(InvestmentHolding).where( InvestmentHolding.user_id == user_id, @@ -60,19 +90,44 @@ async def get_portfolio(db: AsyncSession, user_id: uuid.UUID) -> PortfolioSummar ) holdings = result.scalars().all() + # Pre-fetch all assets and determine which FX pairs we need + assets: dict[uuid.UUID, Asset] = {} + for h in holdings: + if h.asset_id not in assets: + asset = await _get_asset(db, h.asset_id) + if asset: + assets[h.asset_id] = asset + + pairs_needed: set[tuple[str, str]] = set() + for h in holdings: + asset = assets.get(h.asset_id) + if not asset: + continue + if asset.currency != h.currency: + pairs_needed.add((asset.currency, h.currency)) + if h.currency != base_currency: + pairs_needed.add((h.currency, base_currency)) + + fx_rates: dict[tuple[str, str], Decimal] = {} + for from_curr, to_curr in pairs_needed: + fx_rates[(from_curr, to_curr)] = await _fetch_fx_rate(db, from_curr, to_curr) + responses = [] total_value = Decimal("0") total_cost = Decimal("0") for h in holdings: - asset = await _get_asset(db, h.asset_id) + asset = assets.get(h.asset_id) if not asset: continue - r = _holding_to_response(h, asset) + r = _holding_to_response(h, asset, fx_rates) responses.append(r) - total_cost += r.cost_basis_total + + # Convert each holding to base_currency for the portfolio totals + to_base = fx_rates.get((h.currency, base_currency), Decimal("1")) if h.currency != base_currency else Decimal("1") + total_cost += r.cost_basis_total * to_base if r.current_value is not None: - total_value += r.current_value + total_value += r.current_value * to_base total_gain = total_value - total_cost total_gain_pct = (total_gain / total_cost * 100).quantize(Decimal("0.01")) if total_cost > 0 else Decimal("0") @@ -82,7 +137,7 @@ async def get_portfolio(db: AsyncSession, user_id: uuid.UUID) -> PortfolioSummar total_cost=total_cost, total_gain=total_gain, total_gain_pct=total_gain_pct, - currency="GBP", + currency=base_currency, holdings=responses, ) @@ -189,18 +244,131 @@ async def list_investment_transactions( return list(result.scalars().all()) -async def get_performance(db: AsyncSession, user_id: uuid.UUID) -> PerformanceMetrics: - portfolio = await get_portfolio(db, user_id) +async def get_performance(db: AsyncSession, user_id: uuid.UUID, base_currency: str = "GBP") -> PerformanceMetrics: + portfolio = await get_portfolio(db, user_id, base_currency) total_return = portfolio.total_gain total_return_pct = portfolio.total_gain_pct return PerformanceMetrics( twrr=None, # full TWRR requires snapshot history — placeholder total_return=total_return, total_return_pct=total_return_pct, - currency="GBP", + currency=base_currency, ) +def _uk_tax_year(d: date) -> str: + """Return the UK tax year string for a given date (e.g. '2024/25').""" + if d >= date(d.year, 4, 6): + return f"{d.year}/{str(d.year + 1)[2:]}" + return f"{d.year - 1}/{str(d.year)[2:]}" + + +async def get_capital_gains( + db: AsyncSession, user_id: uuid.UUID, base_currency: str = "GBP" +) -> CapitalGainsReport: + """ + Compute capital gains using the UK Section 104 pool method. + Each asset's transactions are replayed chronologically; on each sell + the cost of disposal is (sold_qty / pool_qty) * pool_cost. + All values are converted to base_currency using current FX rates. + """ + holdings_result = await db.execute( + select(InvestmentHolding).where(InvestmentHolding.user_id == user_id) + ) + holdings = holdings_result.scalars().all() + + # Pre-fetch assets and FX rates + assets: dict[uuid.UUID, Asset] = {} + holding_currencies: set[str] = set() + for h in holdings: + if h.asset_id not in assets: + a = await _get_asset(db, h.asset_id) + if a: + assets[h.asset_id] = a + holding_currencies.add(h.currency) + + fx_rates: dict[tuple[str, str], Decimal] = {} + for curr in holding_currencies: + if curr != base_currency: + fx_rates[(curr, base_currency)] = await _fetch_fx_rate(db, curr, base_currency) + + disposals_by_year: dict[str, list[CapitalGainsDisposal]] = {} + + for h in holdings: + asset = assets.get(h.asset_id) + if not asset: + continue + + txns_result = await db.execute( + select(InvestmentTransaction) + .where(InvestmentTransaction.holding_id == h.id) + .order_by(InvestmentTransaction.date.asc(), InvestmentTransaction.created_at.asc()) + ) + txns = txns_result.scalars().all() + + pool_qty = Decimal("0") + pool_cost = Decimal("0") # in holding.currency + + for txn in txns: + if txn.type in ("buy", "transfer_in"): + cost_of_purchase = txn.quantity * txn.price + txn.fees + pool_qty += txn.quantity + pool_cost += cost_of_purchase + + elif txn.type in ("sell", "transfer_out") and pool_qty > 0: + sell_qty = min(txn.quantity, pool_qty) + cost_per_unit = pool_cost / pool_qty + cost_of_disposal = cost_per_unit * sell_qty + proceeds = txn.price * sell_qty - txn.fees + + # Convert to base_currency + to_base = fx_rates.get((h.currency, base_currency), Decimal("1")) if h.currency != base_currency else Decimal("1") + proceeds_base = (proceeds * to_base).quantize(Decimal("0.01")) + cost_base = (cost_of_disposal * to_base).quantize(Decimal("0.01")) + gain_base = proceeds_base - cost_base + + tax_year = _uk_tax_year(txn.date) + disposals_by_year.setdefault(tax_year, []).append( + CapitalGainsDisposal( + date=txn.date, + symbol=asset.symbol, + asset_name=asset.name, + quantity=sell_qty, + proceeds=proceeds_base, + cost=cost_base, + gain=gain_base, + currency=base_currency, + ) + ) + + pool_qty -= sell_qty + pool_cost -= cost_of_disposal + if pool_qty <= 0: + pool_qty = Decimal("0") + pool_cost = Decimal("0") + + elif txn.type == "split" and txn.price > 0: + pool_qty = pool_qty * txn.quantity + # pool_cost stays the same; avg cost per unit changes + + tax_years: list[TaxYearSummary] = [] + for year_label in sorted(disposals_by_year.keys(), reverse=True): + year_disposals = sorted(disposals_by_year[year_label], key=lambda d: d.date) + total_proceeds = sum(d.proceeds for d in year_disposals) + total_cost = sum(d.cost for d in year_disposals) + total_gain = total_proceeds - total_cost + tax_years.append(TaxYearSummary( + tax_year=year_label, + disposals=year_disposals, + total_proceeds=total_proceeds, + total_cost=total_cost, + total_gain=total_gain, + currency=base_currency, + )) + + return CapitalGainsReport(tax_years=tax_years, currency=base_currency) + + async def get_or_create_asset( db: AsyncSession, symbol: str, name: str, asset_type: str, currency: str, data_source: str, data_source_id: str | None, diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index aeb6f09..07eeee1 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -26,6 +26,8 @@ from app.schemas.report import ( IncomeExpenseReport, NetWorthPoint, NetWorthReport, + SavingsRatePoint, + SavingsRateReport, SpendingTrendPoint, SpendingTrendsReport, ) @@ -402,6 +404,50 @@ async def get_balance_sheet( ) +async def get_savings_rate_report( + db: AsyncSession, user_id: uuid.UUID, months: int = 12 +) -> SavingsRateReport: + cutoff = date.today().replace(day=1) - relativedelta(months=months - 1) + result = await db.execute( + text(""" + SELECT + TO_CHAR(date, 'YYYY-MM') AS month, + SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) AS income, + SUM(CASE WHEN type = 'expense' THEN ABS(amount) ELSE 0 END) AS expenses + FROM transactions + WHERE user_id = CAST(:uid AS uuid) + AND status != 'void' + AND deleted_at IS NULL + AND date >= :cutoff + GROUP BY TO_CHAR(date, 'YYYY-MM') + ORDER BY month ASC + """).bindparams(uid=str(user_id), cutoff=cutoff) + ) + rows = result.fetchall() + + points = [] + for row in rows: + inc = Decimal(str(row.income or 0)) + exp = Decimal(str(row.expenses or 0)) + savings = inc - exp + rate = (savings / inc * 100).quantize(Decimal("0.01")) if inc > 0 else Decimal("0") + points.append(SavingsRatePoint( + month=row.month, + income=inc, + expenses=exp, + savings=savings, + savings_rate=rate, + )) + + n = len(points) or 1 + avg_rate = sum(p.savings_rate for p in points) / n + return SavingsRateReport( + points=points, + avg_savings_rate=avg_rate.quantize(Decimal("0.01")), + currency="GBP", + ) + + async def take_net_worth_snapshot(db: AsyncSession, user_id: uuid.UUID, base_currency: str) -> None: today = date.today() existing = await db.execute( diff --git a/backend/app/services/transaction_service.py b/backend/app/services/transaction_service.py index 384593f..9408c66 100644 --- a/backend/app/services/transaction_service.py +++ b/backend/app/services/transaction_service.py @@ -144,6 +144,8 @@ async def list_transactions( conditions.append(Transaction.amount >= filters.min_amount) if filters.max_amount is not None: conditions.append(Transaction.amount <= filters.max_amount) + if filters.is_recurring is not None: + conditions.append(Transaction.is_recurring == filters.is_recurring) query = select(Transaction).where(and_(*conditions)).order_by(Transaction.date.desc(), Transaction.created_at.desc()) diff --git a/backend/app/workers/backup.py b/backend/app/workers/backup.py new file mode 100644 index 0000000..974745f --- /dev/null +++ b/backend/app/workers/backup.py @@ -0,0 +1,25 @@ +""" +Daily encrypted backup job — runs the backup.sh script inside the container. +The script does: pg_dump | gzip | gpg symmetric AES-256 → /app/backups/ +""" +import asyncio +import structlog + +logger = structlog.get_logger() + + +async def backup_job() -> None: + try: + proc = await asyncio.create_subprocess_exec( + "bash", "/app/scripts/backup.sh", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + stdout, _ = await proc.communicate() + output = stdout.decode().strip() if stdout else "" + if proc.returncode == 0: + logger.info("backup_complete", output=output) + else: + logger.error("backup_failed", returncode=proc.returncode, output=output) + except Exception as exc: + logger.error("backup_error", error=str(exc)) diff --git a/backend/app/workers/scheduler.py b/backend/app/workers/scheduler.py index 2f3bc93..21839a4 100644 --- a/backend/app/workers/scheduler.py +++ b/backend/app/workers/scheduler.py @@ -15,12 +15,13 @@ async def start_scheduler() -> None: from app.workers.snapshot import snapshot_job from app.workers.price_sync import price_sync_job from app.workers.fx_sync import fx_sync_job + from app.workers.backup import backup_job _scheduler = AsyncIOScheduler() _scheduler.add_job(snapshot_job, CronTrigger(hour=2, minute=0), id="nw_snapshot") _scheduler.add_job(price_sync_job, CronTrigger(minute="*/15"), id="price_sync") _scheduler.add_job(fx_sync_job, CronTrigger(minute=0), id="fx_sync") - # _scheduler.add_job(backup_job, CronTrigger(hour=3), id="backup") + _scheduler.add_job(backup_job, CronTrigger(hour=3, minute=0), id="backup") # _scheduler.add_job(ml_retrain_job, CronTrigger(day_of_week="sun", hour=1), id="ml_retrain") _scheduler.start() diff --git a/backend/scripts/backup.sh b/backend/scripts/backup.sh new file mode 100755 index 0000000..facd600 --- /dev/null +++ b/backend/scripts/backup.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# backup.sh — pg_dump | gzip | gpg encrypt → /backups/ +# Run inside the backend container: +# docker compose exec backend bash /app/scripts/backup.sh +# Or from host: +# docker compose exec -e BACKUP_PASSPHRASE="$BACKUP_PASSPHRASE" backend bash scripts/backup.sh +set -euo pipefail + +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="${BACKUP_DIR:-/app/backups}" +BACKUP_FILE="${BACKUP_DIR}/${TIMESTAMP}.sql.gz.gpg" +RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}" + +# Require passphrase +if [ -z "${BACKUP_PASSPHRASE:-}" ]; then + echo "[backup] ERROR: BACKUP_PASSPHRASE is not set" >&2 + exit 1 +fi + +mkdir -p "${BACKUP_DIR}" +echo "[backup] Starting at ${TIMESTAMP}" + +# GPG needs a writable home dir; appuser has no real home +export GNUPGHOME=/tmp/.gnupg +mkdir -p "${GNUPGHOME}" +chmod 700 "${GNUPGHOME}" + +# pg_dump using the DATABASE_URL but swap asyncpg driver for psycopg2-compatible URL +PG_URL="${DATABASE_URL/postgresql+asyncpg/postgresql}" + +pg_dump --clean --if-exists "${PG_URL}" \ + | gzip \ + | gpg --batch --yes --no-symkey-cache --pinentry-mode loopback \ + --symmetric --cipher-algo AES256 \ + --passphrase "${BACKUP_PASSPHRASE}" \ + --output "${BACKUP_FILE}" + +SIZE=$(du -sh "${BACKUP_FILE}" | cut -f1) +echo "[backup] Written ${SIZE} → ${BACKUP_FILE}" + +# List current backups +COUNT=$(find "${BACKUP_DIR}" -name "*.sql.gz.gpg" | wc -l) +echo "[backup] ${COUNT} backup(s) on disk" + +# Prune old backups +PRUNED=$(find "${BACKUP_DIR}" -name "*.sql.gz.gpg" -mtime "+${RETENTION_DAYS}" -print -delete | wc -l) +if [ "${PRUNED}" -gt 0 ]; then + echo "[backup] Pruned ${PRUNED} backup(s) older than ${RETENTION_DAYS} days" +fi + +echo "[backup] Done" diff --git a/backend/scripts/entrypoint.sh b/backend/scripts/entrypoint.sh new file mode 100644 index 0000000..0507884 --- /dev/null +++ b/backend/scripts/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +# Fix ownership of bind-mounted directories so appuser can write to them. +# This runs briefly as root before dropping privileges, which is the only +# way to handle host directories that Docker creates as root. +chown -R appuser:appuser /app/backups /app/uploads 2>/dev/null || true + +exec gosu appuser "$@" diff --git a/backend/scripts/restore.sh b/backend/scripts/restore.sh new file mode 100755 index 0000000..4b94ca1 --- /dev/null +++ b/backend/scripts/restore.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# restore.sh — decrypt and restore a backup +# Usage: +# docker compose exec backend bash scripts/restore.sh +# docker compose exec backend bash scripts/restore.sh --list +set -euo pipefail + +BACKUP_DIR="${BACKUP_DIR:-/app/backups}" + +if [ "${1:-}" = "--list" ]; then + echo "[restore] Available backups in ${BACKUP_DIR}:" + find "${BACKUP_DIR}" -name "*.sql.gz.gpg" -printf " %f (%s bytes)\n" | sort -r + exit 0 +fi + +if [ -z "${1:-}" ]; then + echo "Usage: restore.sh " + echo " restore.sh --list" + exit 1 +fi + +BACKUP_FILE="$1" + +# Accept bare filename or full path +if [ ! -f "${BACKUP_FILE}" ] && [ -f "${BACKUP_DIR}/${BACKUP_FILE}" ]; then + BACKUP_FILE="${BACKUP_DIR}/${BACKUP_FILE}" +fi + +if [ ! -f "${BACKUP_FILE}" ]; then + echo "[restore] ERROR: File not found: ${BACKUP_FILE}" >&2 + exit 1 +fi + +if [ -z "${BACKUP_PASSPHRASE:-}" ]; then + echo "[restore] ERROR: BACKUP_PASSPHRASE is not set" >&2 + exit 1 +fi + +PG_URL="${DATABASE_URL/postgresql+asyncpg/postgresql}" + +echo "[restore] WARNING: This will overwrite the current database." +echo "[restore] File: ${BACKUP_FILE}" +read -r -p "[restore] Type 'yes' to continue: " CONFIRM +if [ "${CONFIRM}" != "yes" ]; then + echo "[restore] Aborted" + exit 1 +fi + +echo "[restore] Decrypting and restoring…" +gpg --batch --yes --decrypt \ + --passphrase "${BACKUP_PASSPHRASE}" \ + "${BACKUP_FILE}" \ + | gunzip \ + | psql "${PG_URL}" + +echo "[restore] Done" diff --git a/backend/scripts/rotate_keys.py b/backend/scripts/rotate_keys.py new file mode 100644 index 0000000..ad7c6d1 --- /dev/null +++ b/backend/scripts/rotate_keys.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +AES-256-GCM encryption key rotation. + +Re-encrypts all PII fields with a new key in a single atomic transaction. +If anything fails the database is left unchanged. + +Usage (from inside the backend container): + python /app/scripts/rotate_keys.py --old-key <64-hex> --new-key <64-hex> + + --dry-run Decrypt and re-encrypt in memory only, do not write to DB. + +After the script reports success: + 1. Update ENCRYPTION_KEY in your .env file to the new key. + 2. Restart the backend: docker compose up -d backend +""" +import argparse +import asyncio +import os +import sys +from os import urandom +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +# --------------------------------------------------------------------------- +# Crypto helpers (standalone, no dependency on app code) +# --------------------------------------------------------------------------- + +def _make_decrypt(key_hex: str): + key = bytes.fromhex(key_hex) + if len(key) != 32: + sys.exit("ERROR: key must be 32 bytes (64 hex characters)") + aesgcm = AESGCM(key) + + def decrypt(data: bytes) -> str: + if not data: + return "" + iv, ct = data[:12], data[12:] + return aesgcm.decrypt(iv, ct, None).decode() + + return decrypt + + +def _make_encrypt(key_hex: str): + key = bytes.fromhex(key_hex) + if len(key) != 32: + sys.exit("ERROR: key must be 32 bytes (64 hex characters)") + aesgcm = AESGCM(key) + + def encrypt(plaintext: str) -> bytes: + if not plaintext: + return b"" + iv = urandom(12) + return iv + aesgcm.encrypt(iv, plaintext.encode(), None) + + return encrypt + + +def _rotate_bytes(data: bytes | None, decrypt, encrypt) -> bytes | None: + """Decrypt with old key, re-encrypt with new key. None/empty passes through.""" + if not data: + return data + return encrypt(decrypt(data)) + + +def _rotate_hex(hex_str: str | None, decrypt, encrypt) -> str | None: + """Same as _rotate_bytes but field is stored as hex text (TOTP secret).""" + if not hex_str: + return hex_str + return encrypt(decrypt(bytes.fromhex(hex_str))).hex() + + +# --------------------------------------------------------------------------- +# Main rotation logic +# --------------------------------------------------------------------------- + +async def rotate(old_key: str, new_key: str, dry_run: bool) -> None: + import asyncpg + + decrypt = _make_decrypt(old_key) + encrypt = _make_encrypt(new_key) + + db_url = os.environ.get("DATABASE_URL", "") + if not db_url: + sys.exit("ERROR: DATABASE_URL environment variable not set") + pg_url = db_url.replace("postgresql+asyncpg://", "postgresql://") + + print(f"Connecting to database...") + conn = await asyncpg.connect(pg_url) + + try: + async with conn.transaction(): + # ── accounts ────────────────────────────────────────────────── + print("Rotating accounts (name, institution, notes)...") + rows = await conn.fetch( + "SELECT id, name, institution, notes FROM accounts" + ) + updated = 0 + for row in rows: + new_name = _rotate_bytes(row["name"], decrypt, encrypt) + new_inst = _rotate_bytes(row["institution"], decrypt, encrypt) + new_notes = _rotate_bytes(row["notes"], decrypt, encrypt) + if not dry_run: + await conn.execute( + "UPDATE accounts SET name=$1, institution=$2, notes=$3 WHERE id=$4", + new_name, new_inst, new_notes, row["id"], + ) + updated += 1 + print(f" {'(dry-run) would rotate' if dry_run else 'rotated'} {updated} accounts") + + # ── transactions ────────────────────────────────────────────── + print("Rotating transactions (description, merchant, notes)...") + rows = await conn.fetch( + "SELECT id, description, merchant, notes FROM transactions" + ) + updated = 0 + for row in rows: + new_desc = _rotate_bytes(row["description"], decrypt, encrypt) + new_merch = _rotate_bytes(row["merchant"], decrypt, encrypt) + new_notes = _rotate_bytes(row["notes"], decrypt, encrypt) + if not dry_run: + await conn.execute( + "UPDATE transactions SET description=$1, merchant=$2, notes=$3 WHERE id=$4", + new_desc, new_merch, new_notes, row["id"], + ) + updated += 1 + print(f" {'(dry-run) would rotate' if dry_run else 'rotated'} {updated} transactions") + + # ── investment transactions ──────────────────────────────────── + print("Rotating investment transaction notes...") + rows = await conn.fetch( + "SELECT id, notes FROM investment_transactions WHERE notes IS NOT NULL" + ) + updated = 0 + for row in rows: + new_notes = _rotate_bytes(row["notes"], decrypt, encrypt) + if not dry_run: + await conn.execute( + "UPDATE investment_transactions SET notes=$1 WHERE id=$2", + new_notes, row["id"], + ) + updated += 1 + print(f" {'(dry-run) would rotate' if dry_run else 'rotated'} {updated} investment transaction notes") + + # ── users — TOTP secret (hex-encoded) ───────────────────────── + print("Rotating user TOTP secrets...") + rows = await conn.fetch( + "SELECT id, totp_secret FROM users WHERE totp_secret IS NOT NULL" + ) + updated = 0 + for row in rows: + new_secret = _rotate_hex(row["totp_secret"], decrypt, encrypt) + if not dry_run: + await conn.execute( + "UPDATE users SET totp_secret=$1 WHERE id=$2", + new_secret, row["id"], + ) + updated += 1 + print(f" {'(dry-run) would rotate' if dry_run else 'rotated'} {updated} TOTP secrets") + + if dry_run: + raise _DryRunAbort() + + except _DryRunAbort: + print("\nDry-run complete — no changes written.") + return + finally: + await conn.close() + + print("\n✓ Key rotation complete.") + print("\nNext steps:") + print(" 1. Update ENCRYPTION_KEY in your .env to the new key.") + print(" 2. Restart the backend: docker compose up -d backend") + + +class _DryRunAbort(Exception): + """Raised inside the transaction to trigger an asyncpg rollback in dry-run mode.""" + + +def main(): + parser = argparse.ArgumentParser(description="Rotate AES-256-GCM encryption key") + parser.add_argument("--old-key", required=True, help="Current key as 64-char hex") + parser.add_argument("--new-key", required=True, help="New key as 64-char hex") + parser.add_argument("--dry-run", action="store_true", help="Validate only, do not write") + args = parser.parse_args() + + if args.old_key == args.new_key: + sys.exit("ERROR: old key and new key are identical — nothing to do") + + print(f"{'DRY RUN — ' if args.dry_run else ''}AES key rotation starting...") + asyncio.run(rotate(args.old_key, args.new_key, args.dry_run)) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/rotate_keys.sh b/backend/scripts/rotate_keys.sh new file mode 100755 index 0000000..1450e39 --- /dev/null +++ b/backend/scripts/rotate_keys.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# rotate_keys.sh — re-encrypt all AES-256-GCM fields with a new key. +# The application must be STOPPED before running. +# +# Usage: +# NEW_ENCRYPTION_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))") +# docker compose stop backend +# NEW_ENCRYPTION_KEY="$NEW_ENCRYPTION_KEY" ./scripts/rotate_keys.sh +# # On success, update ENCRYPTION_KEY in .env, then: +# docker compose up -d backend +set -euo pipefail + +if [ -z "${NEW_ENCRYPTION_KEY:-}" ]; then + echo "ERROR: NEW_ENCRYPTION_KEY is not set" + echo "Generate with: python3 -c \"import secrets; print(secrets.token_hex(32))\"" + exit 1 +fi + +if [ -z "${ENCRYPTION_KEY:-}" ]; then + # Try to load from .env in the project root + ENV_FILE="$(dirname "$0")/../.env" + if [ -f "${ENV_FILE}" ]; then + ENCRYPTION_KEY=$(grep -E '^ENCRYPTION_KEY=' "${ENV_FILE}" | cut -d= -f2- | tr -d '"' | tr -d "'") + fi +fi + +if [ -z "${ENCRYPTION_KEY:-}" ]; then + echo "ERROR: ENCRYPTION_KEY (current key) could not be found in environment or .env" + exit 1 +fi + +if [ "${ENCRYPTION_KEY}" = "${NEW_ENCRYPTION_KEY}" ]; then + echo "ERROR: NEW_ENCRYPTION_KEY is the same as the current key — nothing to do" + exit 1 +fi + +echo "[rotate] This will re-encrypt ALL sensitive fields in the database." +echo "[rotate] Ensure the application containers are stopped before continuing." +read -r -p "[rotate] Type 'yes' to continue: " CONFIRM +if [ "${CONFIRM}" != "yes" ]; then + echo "[rotate] Aborted" + exit 1 +fi + +echo "[rotate] Running key rotation inside the backend container…" +docker compose exec \ + -e ENCRYPTION_KEY="${ENCRYPTION_KEY}" \ + -e NEW_ENCRYPTION_KEY="${NEW_ENCRYPTION_KEY}" \ + backend python -m app.core.key_rotation + +echo "" +echo "[rotate] SUCCESS. Next steps:" +echo " 1. Update ENCRYPTION_KEY in .env to: ${NEW_ENCRYPTION_KEY}" +echo " 2. Restart the backend: docker compose up -d backend" diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..d1fce6e --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,32 @@ +import { api } from "./client"; + +export interface BackupFile { + filename: string; + size_bytes: number; + created_at: string; +} + +export async function listBackups(): Promise { + const r = await api.get("/admin/backups"); + return r.data; +} + +export async function triggerBackup(): Promise<{ ok: boolean; message: string }> { + const r = await api.post("/admin/backup"); + return r.data; +} + +export async function downloadBackup(filename: string): Promise { + const r = await api.get(`/admin/backups/${filename}`, { responseType: "blob" }); + const url = URL.createObjectURL(r.data); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export async function restoreBackup(filename: string): Promise<{ ok: boolean; message: string }> { + const r = await api.post(`/admin/restore/${filename}`); + return r.data; +} diff --git a/frontend/src/api/investments.ts b/frontend/src/api/investments.ts index c90315e..262d27e 100644 --- a/frontend/src/api/investments.ts +++ b/frontend/src/api/investments.ts @@ -61,11 +61,23 @@ export interface PricePoint { volume: number | null; } +export interface PerformanceMetrics { + twrr: number | null; + total_return: number; + total_return_pct: number; + currency: string; +} + export async function getPortfolio(): Promise { const r = await api.get("/investments/portfolio"); return r.data; } +export async function getPerformance(): Promise { + const r = await api.get("/investments/performance"); + return r.data; +} + export async function searchAssets(q: string): Promise { const r = await api.get("/assets/search", { params: { q } }); return r.data; @@ -87,6 +99,11 @@ export async function createHolding(data: { return r.data; } +export async function updateHolding(id: string, data: { quantity: number; avg_cost_basis: number }): Promise { + const r = await api.patch(`/investments/holdings/${id}`, data); + return r.data; +} + export async function deleteHolding(id: string): Promise { await api.delete(`/investments/holdings/${id}`); } @@ -109,3 +126,33 @@ export async function getHoldingTransactions(holdingId: string): Promise { + const r = await api.get("/investments/capital-gains"); + return r.data; +} diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts index 2fbdf9c..e79cb72 100644 --- a/frontend/src/api/reports.ts +++ b/frontend/src/api/reports.ts @@ -122,6 +122,25 @@ export async function getSpendingTrends(months = 6): Promise { + const r = await api.get("/reports/savings-rate", { params: { months } }); + return r.data; +} + export interface BalanceSheetAccount { id: string; name: string; diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts index adc1940..e33c45d 100644 --- a/frontend/src/api/transactions.ts +++ b/frontend/src/api/transactions.ts @@ -61,6 +61,7 @@ export interface TransactionFilters { date_from?: string; date_to?: string; search?: string; + is_recurring?: boolean; page?: number; page_size?: number; } diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 8d836e3..e489d1f 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -1,7 +1,7 @@ import { useNavigate } from "react-router-dom"; import { useAuthStore } from "@/store/authStore"; import { logout } from "@/api/auth"; -import { LogOut, User } from "lucide-react"; +import { LogOut, User, Coins } from "lucide-react"; import ThemePicker from "./ThemePicker"; export default function TopBar() { @@ -18,10 +18,16 @@ export default function TopBar() { } return ( -
+
+ {/* Logo visible on mobile only (sidebar is hidden) */} +
+ + MyMidas +
+
-
+
{displayName ?? "User"}
@@ -33,6 +39,7 @@ export default function TopBar() { > +
); } diff --git a/frontend/src/pages/accounts/AccountList.tsx b/frontend/src/pages/accounts/AccountList.tsx index 38761ba..2131463 100644 --- a/frontend/src/pages/accounts/AccountList.tsx +++ b/frontend/src/pages/accounts/AccountList.tsx @@ -99,7 +99,7 @@ export default function AccountList() { {nw && ( -
+
{[ { label: "Total Assets", value: nw.total_assets, positive: true }, { label: "Total Liabilities", value: nw.total_liabilities, positive: nw.total_liabilities === 0 }, @@ -253,7 +253,7 @@ function AccountGroup({ )}
-
+
- setForm(f => ({ ...f, price: e.target.value }))} - className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring" - placeholder="150.00" - /> +
+ + setForm(f => ({ ...f, price: e.target.value }))} + className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="150.00" + /> +
@@ -176,6 +190,12 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
+ {selected && form.currency !== selected.currency && ( +

+ Price in {form.currency} — asset trades in {selected.currency}. Current values will be converted using live exchange rates. +

+ )} +
(); + const baseCurrency = useUiStore(s => s.currency); const { data: portfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio }); const holding = portfolio?.holdings.find(h => h.asset_id === assetId); @@ -46,10 +48,10 @@ export default function AssetDetail() { {/* Price header */} {latestPrice != null && (
-

{formatCurrency(latestPrice, holding?.currency ?? "GBP")}

+

{formatCurrency(latestPrice, holding?.currency ?? baseCurrency)}

{isUp ? : } - {isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? "GBP")} ({changePct.toFixed(2)}%) + {isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? baseCurrency)} ({changePct.toFixed(2)}%)
)} diff --git a/frontend/src/pages/investments/PortfolioPage.tsx b/frontend/src/pages/investments/PortfolioPage.tsx index 90af2c5..3a2e557 100644 --- a/frontend/src/pages/investments/PortfolioPage.tsx +++ b/frontend/src/pages/investments/PortfolioPage.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getPortfolio, deleteHolding } from "@/api/investments"; +import { getPortfolio, deleteHolding, updateHolding } from "@/api/investments"; +import type { HoldingResponse } from "@/api/investments"; import { getAccounts } from "@/api/accounts"; import { formatCurrency } from "@/utils/currency"; import { cn } from "@/utils/cn"; -import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight } from "lucide-react"; +import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight, Pencil, AlertTriangle, X, Loader2 } from "lucide-react"; import AddHoldingModal from "./AddHoldingModal"; import { Link } from "react-router-dom"; @@ -13,9 +14,127 @@ const COLORS = [ "#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444", ]; +function ConfirmDeleteDialog({ holding, onConfirm, onCancel, isPending }: { + holding: HoldingResponse; + onConfirm: () => void; + onCancel: () => void; + isPending: boolean; +}) { + return ( +
+
+
+
+ +
+
+

Delete holding?

+

+ This will permanently remove {holding.symbol} ({holding.asset_name}) and all its transaction history. This cannot be undone. +

+
+
+
+ + +
+
+
+ ); +} + +function EditHoldingModal({ holding, onClose, onSuccess }: { + holding: HoldingResponse; + onClose: () => void; + onSuccess: () => void; +}) { + const [quantity, setQuantity] = useState(String(holding.quantity)); + const [avgCost, setAvgCost] = useState(String(holding.avg_cost_basis)); + const [error, setError] = useState(null); + + const qc = useQueryClient(); + const mutation = useMutation({ + mutationFn: () => updateHolding(holding.id, { + quantity: parseFloat(quantity), + avg_cost_basis: parseFloat(avgCost), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["portfolio"] }); + onSuccess(); + }, + onError: (e: any) => setError(e?.response?.data?.detail ?? "Failed to update holding"), + }); + + return ( +
+
+
+
+

Edit {holding.symbol}

+

{holding.asset_name}

+
+ +
+
+
+ + setQuantity(e.target.value)} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + setAvgCost(e.target.value)} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ {error &&

{error}

} +
+ + +
+
+
+
+ ); +} + export default function PortfolioPage() { const qc = useQueryClient(); const [showAdd, setShowAdd] = useState(false); + const [editHolding, setEditHolding] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteError, setDeleteError] = useState(null); const { data: portfolio, isLoading } = useQuery({ queryKey: ["portfolio"], @@ -27,7 +146,14 @@ export default function PortfolioPage() { const deleteMutation = useMutation({ mutationFn: deleteHolding, - onSuccess: () => qc.invalidateQueries({ queryKey: ["portfolio"] }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["portfolio"] }); + setDeleteTarget(null); + setDeleteError(null); + }, + onError: (e: any) => { + setDeleteError(e?.response?.data?.detail ?? "Failed to delete holding"); + }, }); const treemapData = portfolio?.holdings @@ -75,7 +201,7 @@ export default function PortfolioPage() {
)} - {/* Treemap */} + {/* Allocation bar */} {treemapData.length > 1 && (

Allocation

@@ -127,7 +253,7 @@ export default function PortfolioPage() { Value Gain / Loss 24h - + @@ -173,16 +299,25 @@ export default function PortfolioPage() { ) : "—"} -
+
+ @@ -196,6 +331,13 @@ export default function PortfolioPage() {
)} + {deleteError && ( +
+ + {deleteError} +
+ )} + {showAdd && ( { qc.invalidateQueries({ queryKey: ["portfolio"] }); setShowAdd(false); }} /> )} + + {editHolding && ( + setEditHolding(null)} + onSuccess={() => setEditHolding(null)} + /> + )} + + {deleteTarget && ( + deleteMutation.mutate(deleteTarget.id)} + onCancel={() => { setDeleteTarget(null); setDeleteError(null); }} + isPending={deleteMutation.isPending} + /> + )}
); } diff --git a/frontend/src/pages/reports/ReportsPage.tsx b/frontend/src/pages/reports/ReportsPage.tsx index 4826032..fa2abf3 100644 --- a/frontend/src/pages/reports/ReportsPage.tsx +++ b/frontend/src/pages/reports/ReportsPage.tsx @@ -3,22 +3,30 @@ import { useQuery } from "@tanstack/react-query"; import { getNetWorthReport, getIncomeExpenseReport, + getCashFlowReport, getCategoryBreakdown, getBudgetVsActual, getSpendingTrends, + getSavingsRate, getBalanceSheet, } from "@/api/reports"; +import { getPortfolio, getPerformance, getCapitalGains } from "@/api/investments"; +import type { TaxYearSummary } from "@/api/investments"; import type { BalanceSheetGroup } from "@/api/reports"; import { formatCurrency } from "@/utils/currency"; import { cn } from "@/utils/cn"; import { - AreaChart, Area, BarChart, Bar, + AreaChart, Area, BarChart, Bar, ComposedChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"; -import { TrendingUp, TrendingDown, Minus, Landmark, CreditCard } from "lucide-react"; +import { TrendingUp, TrendingDown, Minus, Landmark, CreditCard, PiggyBank } from "lucide-react"; -const TABS = ["Balance Sheet", "Net Worth", "Income vs Expense", "Categories", "Budget vs Actual", "Spending Trends"] as const; +const TABS = [ + "Balance Sheet", "Net Worth", "Income vs Expense", + "Cash Flow", "Savings Rate", "Categories", + "Budget vs Actual", "Spending Trends", "Investments", "Capital Gains", +] as const; type Tab = typeof TABS[number]; const COLORS = [ @@ -70,7 +78,6 @@ function BalanceSheetTab() { const noAccounts = data.asset_groups.length === 0 && data.liability_groups.length === 0; if (noAccounts) return ; - // Build stacked bar data: one bar for assets, one for liabilities const assetBarData = data.asset_groups.map((g, i) => ({ name: g.label, value: Number(g.subtotal), @@ -81,13 +88,10 @@ function BalanceSheetTab() { value: Number(g.subtotal), color: LIABILITY_COLORS[i % LIABILITY_COLORS.length], })); - - // Single stacked bar chart showing asset composition vs liability composition const maxVal = Math.max(Number(data.total_assets), Number(data.total_liabilities), 1); return (
- {/* Summary KPIs */}

Total Assets

@@ -105,11 +109,8 @@ function BalanceSheetTab() {
- {/* Visual proportion bars */}

Asset & Liability Composition

- - {/* Assets bar */} {assetBarData.length > 0 && (
@@ -120,16 +121,9 @@ function BalanceSheetTab() {
{assetBarData.map((seg) => ( -
0 ? "2px" : "0", - }} - title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} - /> +
0 ? "2px" : "0" }} + title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} /> ))}
@@ -142,8 +136,6 @@ function BalanceSheetTab() {
)} - - {/* Liabilities bar */} {liabilityBarData.length > 0 && (
@@ -154,16 +146,9 @@ function BalanceSheetTab() {
{liabilityBarData.map((seg) => ( -
0 ? "2px" : "0", - }} - title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} - /> +
0 ? "2px" : "0" }} + title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} /> ))}
@@ -178,9 +163,7 @@ function BalanceSheetTab() { )}
- {/* Side-by-side account breakdown */}
- {/* Assets */}

@@ -205,7 +188,6 @@ function BalanceSheetTab() {

- {/* Liabilities */}

@@ -320,40 +302,259 @@ function IncomeExpenseTab() { ); } +function CashFlowTab() { + const { data, isLoading } = useQuery({ + queryKey: ["report-cash-flow"], + queryFn: () => getCashFlowReport(), + }); + if (isLoading) return ; + if (!data) return null; + + const chartData = data.points.map(p => ({ + date: p.date, + inflow: Number(p.inflow), + outflow: Number(p.outflow), + balance: Number(p.running_balance), + })); + + return ( +
+
+
+

Total Inflow

+

{formatCurrency(Number(data.total_inflow), data.currency)}

+
+
+

Total Outflow

+

{formatCurrency(Number(data.total_outflow), data.currency)}

+
+
+

Net

+

= 0 ? "text-success" : "text-destructive")}> + {formatCurrency(Number(data.total_inflow) - Number(data.total_outflow), data.currency)} +

+
+
+ {chartData.length === 0 ? : ( +
+

Daily Cash Flow — Last 30 Days

+ + + + + `£${(v/1000).toFixed(1)}k`} /> + `£${(v/1000).toFixed(1)}k`} /> + formatCurrency(v, data.currency)} /> + + + + + + +
+ )} +
+ ); +} + +function SavingsRateTab() { + const { data, isLoading } = useQuery({ queryKey: ["report-savings-rate"], queryFn: () => getSavingsRate(12) }); + if (isLoading) return ; + if (!data || data.points.length === 0) return ; + + const chartData = data.points.map(p => ({ + month: p.month, + income: Number(p.income), + expenses: Number(p.expenses), + savings: Number(p.savings), + rate: Number(p.savings_rate), + })); + const avg = Number(data.avg_savings_rate); + + return ( +
+
+
+

Avg Savings Rate

+

= 20 ? "text-success" : avg >= 0 ? "text-warning" : "text-destructive")}> + {avg >= 0 ? "" : ""}{avg.toFixed(1)}% +

+

over {data.points.length} months

+
+
+ +
+

Best Month

+

+ {chartData.reduce((best, p) => p.rate > best.rate ? p : best, chartData[0])?.month ?? "—"} +

+
+
+
+
+

Latest Month Savings

+

+ {formatCurrency(chartData[chartData.length - 1]?.savings ?? 0, data.currency)} +

+
+
+
+ +
+

Savings Rate by Month

+ + + + + `£${(v/1000).toFixed(0)}k`} /> + `${v}%`} /> + name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} /> + + + + + + +
+ +
+ + + + + + + + + + + + {[...chartData].reverse().map((row) => ( + + + + + + + + ))} + +
MonthIncomeExpensesSavedRate
{row.month}{formatCurrency(row.income, data.currency)}{formatCurrency(row.expenses, data.currency)}= 0 ? "text-success" : "text-destructive")}> + {formatCurrency(row.savings, data.currency)} + = 20 ? "text-success" : row.rate >= 0 ? "text-foreground" : "text-destructive")}> + {row.rate.toFixed(1)}% +
+
+
+ ); +} + function CategoriesTab() { + const [drillCategory, setDrillCategory] = useState(null); const { data, isLoading } = useQuery({ queryKey: ["report-categories"], queryFn: () => getCategoryBreakdown() }); if (isLoading) return ; if (!data) return null; - const pieData = data.items.slice(0, 10).map(i => ({ name: i.category_name, value: Number(i.amount) })); + const pieData = data.items.slice(0, 10).map(i => ({ name: i.category_name, value: Number(i.amount), category_id: i.category_id })); + const activeIndex = drillCategory ? pieData.findIndex(p => p.name === drillCategory) : -1; + return (

Expense Breakdown — This Month

-

Total: {formatCurrency(Number(data.total), data.currency)}

+

Total: {formatCurrency(Number(data.total), data.currency)} + {drillCategory && · } +

{pieData.length === 0 ? : ( -
+
- - {pieData.map((_, i) => )} + setDrillCategory(drillCategory === entry.name ? null : entry.name)} + style={{ cursor: "pointer" }} + > + {pieData.map((_, i) => ( + + ))} formatCurrency(v, data.currency)} /> -
+
{data.items.slice(0, 10).map((item, i) => ( -
+ ))}
)}
+ {drillCategory && ( +
+

Transactions in {drillCategory} this month

+ i.category_name === drillCategory)?.category_id ?? null} currency={data.currency} dateFrom={data.date_from} dateTo={data.date_to} /> +
+ )} +
+ ); +} + +function DrillDownTransactions({ categoryId, currency, dateFrom, dateTo }: { + categoryId: string | null; currency: string; dateFrom: string; dateTo: string; +}) { + const { data, isLoading } = useQuery({ + queryKey: ["drilldown-txns", categoryId, dateFrom, dateTo], + queryFn: async () => { + const { getTransactions } = await import("@/api/transactions"); + return getTransactions({ + category_id: categoryId ?? undefined, + date_from: dateFrom, + date_to: dateTo, + page_size: 50, + }); + }, + enabled: !!categoryId, + }); + + if (!categoryId) return

Uncategorised transactions cannot be filtered here.

; + if (isLoading) return
; + if (!data?.items.length) return

No transactions found.

; + + return ( +
+ {data.items.map((txn) => ( +
+
+

{txn.description}

+

{txn.date}

+
+ + {formatCurrency(Math.abs(Number(txn.amount)), currency)} + +
+ ))}
); } @@ -427,6 +628,207 @@ function SpendingTrendsTab() { ); } +function InvestmentsTab() { + const { data: portfolio, isLoading: loadingPortfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio }); + const { data: perf, isLoading: loadingPerf } = useQuery({ queryKey: ["investment-performance"], queryFn: getPerformance }); + + if (loadingPortfolio || loadingPerf) return ; + if (!portfolio || !perf) return null; + + const noHoldings = portfolio.holdings.length === 0; + if (noHoldings) return ; + + const holdingsData = portfolio.holdings.map(h => ({ + name: h.symbol, + value: Number(h.current_value ?? 0), + gain: Number(h.unrealised_gain ?? 0), + gainPct: Number(h.unrealised_gain_pct ?? 0), + })); + + return ( +
+
+
+

Portfolio Value

+

{formatCurrency(Number(portfolio.total_value), perf.currency)}

+
+
+

Total Cost

+

{formatCurrency(Number(portfolio.total_cost), perf.currency)}

+
+
+

Total Return

+

= 0 ? "text-success" : "text-destructive")}> + {Number(perf.total_return) >= 0 ? "+" : ""}{formatCurrency(Number(perf.total_return), perf.currency)} +

+
+
+

Return %

+

= 0 ? "text-success" : "text-destructive")}> + {Number(perf.total_return_pct) >= 0 ? "+" : ""}{Number(perf.total_return_pct).toFixed(2)}% +

+
+
+ +
+

Holdings Value

+ + + + `£${(v/1000).toFixed(0)}k`} /> + + formatCurrency(v, perf.currency)} /> + + {holdingsData.map((entry, i) => ( + = 0 ? "#22c55e" : "#ef4444"} /> + ))} + + + +
+ +
+ + + + + + + + + + + + + {portfolio.holdings.map((h) => ( + + + + + + + + + ))} + +
AssetQtyAvg CostCurrent PriceValueGain / Loss
+

{h.symbol}

+

{h.asset_name}

+
{Number(h.quantity).toFixed(4)}{formatCurrency(Number(h.avg_cost_basis), h.currency)}{h.current_price != null ? formatCurrency(Number(h.current_price), h.currency) : "—"}{h.current_value != null ? formatCurrency(Number(h.current_value), h.currency) : "—"}= 0 ? "text-success" : "text-destructive")}> + {h.unrealised_gain != null ? ( + <> + {Number(h.unrealised_gain) >= 0 ? "+" : ""}{formatCurrency(Number(h.unrealised_gain), h.currency)} + ({Number(h.unrealised_gain_pct ?? 0).toFixed(1)}%) + + ) : "—"} +
+
+
+ ); +} + +function CapitalGainsTab() { + const { data, isLoading } = useQuery({ queryKey: ["capital-gains"], queryFn: getCapitalGains }); + const [selectedYear, setSelectedYear] = useState(null); + + if (isLoading) return ; + if (!data || data.tax_years.length === 0) { + return ; + } + + const activeYear: TaxYearSummary = data.tax_years.find(y => y.tax_year === selectedYear) ?? data.tax_years[0]; + + return ( +
+
+

UK tax year:

+
+ {data.tax_years.map(y => ( + + ))} +
+
+ +
+
+

Total Proceeds

+

{formatCurrency(Number(activeYear.total_proceeds), activeYear.currency)}

+
+
+

Total Cost

+

{formatCurrency(Number(activeYear.total_cost), activeYear.currency)}

+
+
+

Net Gain / Loss

+

= 0 ? "text-success" : "text-destructive")}> + {Number(activeYear.total_gain) >= 0 ? "+" : ""}{formatCurrency(Number(activeYear.total_gain), activeYear.currency)} +

+
+
+ +
+ + + + + + + + + + + + + {activeYear.disposals.map((d, i) => { + const gain = Number(d.gain); + return ( + + + + + + + + + ); + })} + + + + + + + + + +
DateAssetQtyProceedsCostGain / Loss
{d.date} +

{d.symbol}

+

{d.asset_name}

+
{Number(d.quantity).toFixed(4)}{formatCurrency(Number(d.proceeds), d.currency)}{formatCurrency(Number(d.cost), d.currency)}= 0 ? "text-success" : "text-destructive")}> + {gain >= 0 ? "+" : ""}{formatCurrency(gain, d.currency)} +
Total ({activeYear.disposals.length} disposal{activeYear.disposals.length !== 1 ? "s" : ""}){formatCurrency(Number(activeYear.total_proceeds), activeYear.currency)}{formatCurrency(Number(activeYear.total_cost), activeYear.currency)}= 0 ? "text-success" : "text-destructive")}> + {Number(activeYear.total_gain) >= 0 ? "+" : ""}{formatCurrency(Number(activeYear.total_gain), activeYear.currency)} +
+
+ +

+ Calculated using the UK Section 104 pool method. Currency conversion uses current exchange rates as an approximation for historical trades. Not financial advice — verify with a qualified accountant before filing. +

+
+ ); +} + function ChartSkeleton() { return (
@@ -478,9 +880,13 @@ export default function ReportsPage() { {activeTab === "Balance Sheet" && } {activeTab === "Net Worth" && } {activeTab === "Income vs Expense" && } + {activeTab === "Cash Flow" && } + {activeTab === "Savings Rate" && } {activeTab === "Categories" && } {activeTab === "Budget vs Actual" && } {activeTab === "Spending Trends" && } + {activeTab === "Investments" && } + {activeTab === "Capital Gains" && }
); diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index ffd5ac9..49dc1ce 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -7,12 +7,14 @@ import { getTotpSetup, enableTotp, disableTotp, changePassword, updateProfile, exportData, getMe, } from "@/api/auth"; +import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin"; +import type { BackupFile } from "@/api/admin"; import { cn } from "@/utils/cn"; import { format } from "date-fns"; import { - User, Shield, MonitorSmartphone, Download, + User, Shield, MonitorSmartphone, Download, HardDrive, Loader2, CheckCircle, Eye, EyeOff, Trash2, - LogOut, QrCode, KeyRound, AlertTriangle, + LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw, } from "lucide-react"; const SECTIONS = [ @@ -20,6 +22,7 @@ const SECTIONS = [ { id: "security", label: "Security", icon: Shield }, { id: "sessions", label: "Sessions", icon: MonitorSmartphone }, { id: "data", label: "Data", icon: Download }, + { id: "backups", label: "Backups", icon: HardDrive }, ] as const; type Section = (typeof SECTIONS)[number]["id"]; @@ -60,6 +63,7 @@ export default function SettingsPage() { {section === "security" && } {section === "sessions" && } {section === "data" && } + {section === "backups" && }
@@ -483,6 +487,167 @@ function SessionsSection() { ); } +// ─── Backups ────────────────────────────────────────────────────────────────── + +function BackupsSection() { + const qc = useQueryClient(); + const [restoreTarget, setRestoreTarget] = useState(null); + const [restoreSuccess, setRestoreSuccess] = useState(""); + const [restoreError, setRestoreError] = useState(""); + + const { data: backups = [], isLoading } = useQuery({ + queryKey: ["backups"], + queryFn: listBackups, + }); + + const triggerMutation = useMutation({ + mutationFn: triggerBackup, + onSuccess: () => qc.invalidateQueries({ queryKey: ["backups"] }), + }); + + const restoreMutation = useMutation({ + mutationFn: (filename: string) => restoreBackup(filename), + onSuccess: (_, filename) => { + setRestoreTarget(null); + setRestoreSuccess(`Restored from ${filename}. Reload the page to see updated data.`); + setRestoreError(""); + setTimeout(() => setRestoreSuccess(""), 8000); + }, + onError: (e: any) => { + setRestoreError(e?.response?.data?.detail ?? "Restore failed"); + setRestoreTarget(null); + }, + }); + + function formatSize(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + return ( +
+ {/* Trigger */} +
+
+ + Database Backups +
+

+ Backups run automatically at 3am daily. Each is GPG-encrypted with your backup passphrase and stored at /app/backups. +

+ + {triggerMutation.isSuccess && } + {triggerMutation.isError && } + + +
+ + {/* Backup list */} +
+
+ Stored Backups + +
+ + {restoreSuccess && } + {restoreError && } + + {isLoading ? ( +
+ {[1,2,3].map(i =>
)} +
+ ) : backups.length === 0 ? ( +

No backups yet — click "Backup now" to create one.

+ ) : ( +
+ {backups.map((b: BackupFile) => ( +
+ +
+

{b.filename}

+

+ {formatSize(b.size_bytes)} · {format(new Date(b.created_at), "dd MMM yyyy HH:mm")} +

+
+
+ + +
+
+ ))} +
+ )} +
+ + {/* Restore confirmation dialog */} + {restoreTarget && ( +
+
+
+
+ +
+
+

Restore this backup?

+

+ This will overwrite all current data with the contents of: +

+

{restoreTarget}

+

This cannot be undone. Consider downloading your current backup first.

+
+
+
+ + +
+
+
+ )} +
+ ); +} + // ─── Data ───────────────────────────────────────────────────────────────────── function DataSection() { diff --git a/frontend/src/pages/transactions/TransactionDetailDrawer.tsx b/frontend/src/pages/transactions/TransactionDetailDrawer.tsx index e03e297..3dfc329 100644 --- a/frontend/src/pages/transactions/TransactionDetailDrawer.tsx +++ b/frontend/src/pages/transactions/TransactionDetailDrawer.tsx @@ -196,7 +196,7 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
-
+
- Import CSV + Import CSV
+ {/* Date presets */} +
+ {DATE_PRESETS.map((p) => ( + + ))} + +
+ {/* Filters */}
@@ -172,7 +225,8 @@ export default function TransactionList() { onClick={() => setSelectedTxn(txn)} > - {format(new Date(txn.date), "dd MMM yyyy")} + {format(new Date(txn.date), "dd MMM yyyy")} + {format(new Date(txn.date), "dd MMM")}
@@ -205,7 +259,7 @@ export default function TransactionList() { e.stopPropagation()}>