Complete Phase 3, Phase 5 polish and hardening

Phase 3 — Investments:
- Multi-currency support: holdings track purchase currency, FX rates convert to base for totals
- Capital gains report using UK Section 104 pool method, grouped by tax year
- Capital Gains tab added to Reports page

Phase 5 — Polish & Hardening:
- Mobile-responsive layout: bottom nav, sidebar hidden on mobile, logo in TopBar, compact header buttons, hover-only actions now always visible on touch
- Backup system: encrypted GPG backups via backup.sh, nightly scheduler job, admin API (list/trigger/download/restore), Settings UI with drag-to-restore confirmation
- Docker entrypoint with gosu privilege drop to fix bind-mount ownership on fresh deployments
- OWASP fixes: refresh token now bound to its session (new refresh_token_hash column + migration), CSRF secure flag tied to environment, IP-level rate limiting on login, TOTPEnableRequest Pydantic schema replaces raw dict
- AES-256-GCM key rotation script (rotate_keys.py) with dry-run mode and atomic DB transaction
- CLAUDE.md added for AI-assisted development context
- README updated: correct reverse proxy port, accurate backup/restore commands, key rotation instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-22 14:59:11 +00:00
parent 74e57a35c0
commit fe4e69b9ad
40 changed files with 2079 additions and 127 deletions

129
CLAUDE.md Normal file
View file

@ -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 `<html>`.
---
## 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`)

View file

@ -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 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 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) 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` 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 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 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 ### 5. Point your reverse proxy
Forward your domain to `http://<host>:8090`. The frontend is served from port `4000` internally but the backend proxies frontend assets — point everything at `8090`. Forward your domain to `http://<host>:4000`. The frontend nginx serves the React app and proxies all `/api/` requests to the backend internally.
--- ---
## Backups ## 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 ```bash
# Manual backup docker compose exec backend bash /app/scripts/backup.sh
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
``` ```
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 ## Key Rotation
To rotate the AES-256-GCM encryption key without data loss: To rotate the AES-256-GCM encryption key without data loss:
```bash ```bash
# Generate new key # Generate a new key
NEW_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))") NEW_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
echo "New key: $NEW_KEY"
# Stop the app, rotate, update .env, restart # Dry-run first — validates decryption works, no DB changes
docker compose stop backend docker compose exec backend python /app/scripts/rotate_keys.py \
NEW_ENCRYPTION_KEY="$NEW_KEY" ./scripts/rotate_keys.sh --old-key "$ENCRYPTION_KEY" --new-key "$NEW_KEY" --dry-run
# Update ENCRYPTION_KEY in .env to $NEW_KEY
# 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 docker compose up -d backend
``` ```
Do not lose your current `ENCRYPTION_KEY` — without it, all encrypted fields are permanently unreadable.
--- ---
## Environment Variables ## Environment Variables
@ -242,7 +260,7 @@ MyMidas/
│ ├── pages/ # Route-level page components │ ├── pages/ # Route-level page components
│ └── store/ # Zustand state (auth, UI/theme) │ └── store/ # Zustand state (auth, UI/theme)
├── postgres/init/ # PostgreSQL init SQL (extensions, RLS policies) ├── 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) ├── secrets/ # JWT keys (git-ignored, generate locally)
└── docker-compose.yml └── docker-compose.yml
``` ```

View file

@ -1,6 +1,10 @@
FROM python:3.12-slim AS base FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libmagic1 \ libmagic1 \
postgresql-client \
gnupg \
gzip \
gosu \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir uv RUN pip install --no-cache-dir uv
WORKDIR /app WORKDIR /app
@ -13,7 +17,13 @@ FROM deps AS production
COPY app/ ./app/ COPY app/ ./app/
COPY alembic/ ./alembic/ COPY alembic/ ./alembic/
COPY alembic.ini ./ COPY alembic.ini ./
RUN useradd -r -s /bin/false -u 1001 appuser && chown -R appuser /app && mkdir -p /app/uploads && chown appuser /app/uploads COPY scripts/ ./scripts/
USER appuser 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 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"] CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 --proxy-headers"]

View file

@ -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")

View file

@ -1,6 +1,6 @@
from fastapi import APIRouter 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 = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["auth"]) 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(reports.router)
router.include_router(investments.router) router.include_router(investments.router)
router.include_router(predictions.router) router.include_router(predictions.router)
router.include_router(admin.router)

152
backend/app/api/v1/admin.py Normal file
View file

@ -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))

View file

@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import write_audit from app.core.audit import write_audit
from app.core.rate_limiter import is_rate_limited 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.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.dependencies import get_current_user, get_db, get_redis
from app.schemas.auth import ( from app.schemas.auth import (
LoginRequest, LoginRequest,
@ -96,6 +97,11 @@ async def login(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis), 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: try:
user, access_token, refresh_token = await authenticate_user( user, access_token, refresh_token = await authenticate_user(
db, redis, body.email, body.password, _ip(request), _ua(request) db, redis, body.email, body.password, _ip(request), _ua(request)
@ -171,24 +177,27 @@ async def refresh_token(
user_id = uuid.UUID(payload["sub"]) user_id = uuid.UUID(payload["sub"])
now = datetime.now(timezone.utc) 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( result = await db.execute(
select(Session).where( select(Session).where(
Session.user_id == user_id, Session.user_id == user_id,
Session.refresh_token_hash == refresh_hash,
Session.revoked_at.is_(None), Session.revoked_at.is_(None),
Session.expires_at > now, Session.expires_at > now,
) )
) )
session = result.scalars().first() session = result.scalars().first()
if not session: 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_access = create_access_token(str(user_id))
new_refresh = create_refresh_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.token_hash = hash_token(new_access)
session.refresh_token_hash = hash_token(new_refresh)
session.last_active_at = now session.last_active_at = now
await db.commit() await db.commit()
@ -305,17 +314,13 @@ async def totp_verify(
@router.post("/totp/enable", status_code=200) @router.post("/totp/enable", status_code=200)
async def totp_enable( async def totp_enable(
body: dict, body: TOTPEnableRequest,
request: Request, request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(get_current_user), 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: try:
await enable_totp(user, db, secret, code) await enable_totp(user, db, body.secret, body.code)
except AuthError as e: except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail) 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 write_audit(db, user_id=user.id, action="totp_enable", ip_address=_ip(request))

View file

@ -1,7 +1,9 @@
import uuid import uuid
from datetime import date 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 sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db 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 ( from app.schemas.investment import (
AssetSearch, AssetSearch,
AssetPricePoint, AssetPricePoint,
CapitalGainsReport,
HoldingCreate, HoldingCreate,
HoldingResponse, HoldingResponse,
InvestmentTxnCreate, InvestmentTxnCreate,
@ -29,7 +32,7 @@ async def get_portfolio(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), 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) @router.get("/investments/performance", response_model=PerformanceMetrics)
@ -37,7 +40,15 @@ async def get_performance(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), 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 ─────────────────────────────────────────────────────────────── # ── Holdings ───────────────────────────────────────────────────────────────
@ -61,6 +72,27 @@ async def create_holding(
return investment_service._holding_to_response(holding, asset) 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) @router.delete("/investments/holdings/{holding_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_holding( async def delete_holding(
holding_id: uuid.UUID, holding_id: uuid.UUID,
@ -70,6 +102,8 @@ async def delete_holding(
holding = await investment_service.get_holding(db, current_user.id, holding_id) holding = await investment_service.get_holding(db, current_user.id, holding_id)
if not holding: if not holding:
raise HTTPException(status_code=404, detail="Holding not found") 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.delete(holding)
await db.commit() await db.commit()

View file

@ -12,6 +12,7 @@ from app.schemas.report import (
CategoryBreakdownReport, CategoryBreakdownReport,
IncomeExpenseReport, IncomeExpenseReport,
NetWorthReport, NetWorthReport,
SavingsRateReport,
SpendingTrendsReport, SpendingTrendsReport,
) )
from app.services import report_service 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) 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) @router.get("/balance-sheet", response_model=BalanceSheetReport)
async def balance_sheet( async def balance_sheet(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),

View file

@ -48,6 +48,7 @@ async def get_transactions(
date_from: str | None = None, date_from: str | None = None,
date_to: str | None = None, date_to: str | None = None,
search: str | None = None, search: str | None = None,
is_recurring: bool | None = None,
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200), page_size: int = Query(default=50, ge=1, le=200),
db: AsyncSession = Depends(get_db), 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_from=date.fromisoformat(date_from) if date_from else None,
date_to=date.fromisoformat(date_to) if date_to else None, date_to=date.fromisoformat(date_to) if date_to else None,
search=search, search=search,
is_recurring=is_recurring,
page=page, page=page,
page_size=page_size, page_size=page_size,
) )

View file

@ -7,6 +7,8 @@ from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from app.config import get_settings
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
SECURITY_HEADERS = { SECURITY_HEADERS = {
@ -55,7 +57,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"csrf_token", token, "csrf_token", token,
httponly=False, # must be readable by JS httponly=False, # must be readable by JS
samesite="lax", samesite="lax",
secure=False, # set True if TLS is terminated at this service secure=not get_settings().is_development,
) )
return response return response
@ -63,7 +65,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
response = await call_next(request) response = await call_next(request)
if not existing_csrf: if not existing_csrf:
token = str(uuid.uuid4()) 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 return response
if request.url.path in {"/api/v1/auth/login", "/api/v1/auth/login/totp"}: if request.url.path in {"/api/v1/auth/login", "/api/v1/auth/login/totp"}:

View file

@ -14,6 +14,7 @@ class Session(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 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) 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) 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) ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
last_active_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) last_active_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

View file

@ -52,6 +52,11 @@ class TOTPVerifyRequest(BaseModel):
code: str code: str
class TOTPEnableRequest(BaseModel):
secret: str
code: str
class SessionInfo(BaseModel): class SessionInfo(BaseModel):
id: uuid.UUID id: uuid.UUID
ip_address: str | None ip_address: str | None

View file

@ -101,3 +101,28 @@ class PerformanceMetrics(BaseModel):
total_return: Decimal total_return: Decimal
total_return_pct: Decimal total_return_pct: Decimal
currency: str 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

View file

@ -96,6 +96,20 @@ class SpendingTrendsReport(BaseModel):
currency: str 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): class BalanceSheetAccount(BaseModel):
id: str id: str
name: str name: str

View file

@ -48,6 +48,7 @@ class TransactionFilter(BaseModel):
max_amount: Decimal | None = None max_amount: Decimal | None = None
search: str | None = None search: str | None = None
tags: list[str] = [] tags: list[str] = []
is_recurring: bool | None = None
page: int = Field(default=1, ge=1) page: int = Field(default=1, ge=1)
page_size: int = Field(default=50, ge=1, le=200) page_size: int = Field(default=50, ge=1, le=200)

View file

@ -137,6 +137,7 @@ async def _create_session(
session = Session( session = Session(
user_id=user.id, user_id=user.id,
token_hash=hash_token(access_token), token_hash=hash_token(access_token),
refresh_token_hash=hash_token(refresh_token),
ip_address=ip, ip_address=ip,
user_agent=user_agent, user_agent=user_agent,
last_active_at=now, last_active_at=now,

View file

@ -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_holding import InvestmentHolding
from app.db.models.investment_transaction import InvestmentTransaction from app.db.models.investment_transaction import InvestmentTransaction
from app.schemas.investment import ( from app.schemas.investment import (
CapitalGainsDisposal,
CapitalGainsReport,
HoldingCreate, HoldingCreate,
HoldingResponse, HoldingResponse,
InvestmentTxnCreate, InvestmentTxnCreate,
PerformanceMetrics, PerformanceMetrics,
PortfolioSummary, PortfolioSummary,
TaxYearSummary,
) )
@ -23,10 +26,37 @@ async def _get_asset(db: AsyncSession, asset_id: uuid.UUID) -> Asset | None:
return result.scalar_one_or_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 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 = (current_value - cost_basis_total) if current_value is not None else None
unrealised_gain_pct = None unrealised_gain_pct = None
if unrealised_gain is not None and cost_basis_total > 0: 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( result = await db.execute(
select(InvestmentHolding).where( select(InvestmentHolding).where(
InvestmentHolding.user_id == user_id, InvestmentHolding.user_id == user_id,
@ -60,19 +90,44 @@ async def get_portfolio(db: AsyncSession, user_id: uuid.UUID) -> PortfolioSummar
) )
holdings = result.scalars().all() 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 = [] responses = []
total_value = Decimal("0") total_value = Decimal("0")
total_cost = Decimal("0") total_cost = Decimal("0")
for h in holdings: for h in holdings:
asset = await _get_asset(db, h.asset_id) asset = assets.get(h.asset_id)
if not asset: if not asset:
continue continue
r = _holding_to_response(h, asset) r = _holding_to_response(h, asset, fx_rates)
responses.append(r) 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: 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 = total_value - total_cost
total_gain_pct = (total_gain / total_cost * 100).quantize(Decimal("0.01")) if total_cost > 0 else Decimal("0") 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_cost=total_cost,
total_gain=total_gain, total_gain=total_gain,
total_gain_pct=total_gain_pct, total_gain_pct=total_gain_pct,
currency="GBP", currency=base_currency,
holdings=responses, holdings=responses,
) )
@ -189,18 +244,131 @@ async def list_investment_transactions(
return list(result.scalars().all()) return list(result.scalars().all())
async def get_performance(db: AsyncSession, user_id: uuid.UUID) -> PerformanceMetrics: async def get_performance(db: AsyncSession, user_id: uuid.UUID, base_currency: str = "GBP") -> PerformanceMetrics:
portfolio = await get_portfolio(db, user_id) portfolio = await get_portfolio(db, user_id, base_currency)
total_return = portfolio.total_gain total_return = portfolio.total_gain
total_return_pct = portfolio.total_gain_pct total_return_pct = portfolio.total_gain_pct
return PerformanceMetrics( return PerformanceMetrics(
twrr=None, # full TWRR requires snapshot history — placeholder twrr=None, # full TWRR requires snapshot history — placeholder
total_return=total_return, total_return=total_return,
total_return_pct=total_return_pct, 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( async def get_or_create_asset(
db: AsyncSession, symbol: str, name: str, asset_type: str, db: AsyncSession, symbol: str, name: str, asset_type: str,
currency: str, data_source: str, data_source_id: str | None, currency: str, data_source: str, data_source_id: str | None,

View file

@ -26,6 +26,8 @@ from app.schemas.report import (
IncomeExpenseReport, IncomeExpenseReport,
NetWorthPoint, NetWorthPoint,
NetWorthReport, NetWorthReport,
SavingsRatePoint,
SavingsRateReport,
SpendingTrendPoint, SpendingTrendPoint,
SpendingTrendsReport, 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: async def take_net_worth_snapshot(db: AsyncSession, user_id: uuid.UUID, base_currency: str) -> None:
today = date.today() today = date.today()
existing = await db.execute( existing = await db.execute(

View file

@ -144,6 +144,8 @@ async def list_transactions(
conditions.append(Transaction.amount >= filters.min_amount) conditions.append(Transaction.amount >= filters.min_amount)
if filters.max_amount is not None: if filters.max_amount is not None:
conditions.append(Transaction.amount <= filters.max_amount) 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()) query = select(Transaction).where(and_(*conditions)).order_by(Transaction.date.desc(), Transaction.created_at.desc())

View file

@ -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))

View file

@ -15,12 +15,13 @@ async def start_scheduler() -> None:
from app.workers.snapshot import snapshot_job from app.workers.snapshot import snapshot_job
from app.workers.price_sync import price_sync_job from app.workers.price_sync import price_sync_job
from app.workers.fx_sync import fx_sync_job from app.workers.fx_sync import fx_sync_job
from app.workers.backup import backup_job
_scheduler = AsyncIOScheduler() _scheduler = AsyncIOScheduler()
_scheduler.add_job(snapshot_job, CronTrigger(hour=2, minute=0), id="nw_snapshot") _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(price_sync_job, CronTrigger(minute="*/15"), id="price_sync")
_scheduler.add_job(fx_sync_job, CronTrigger(minute=0), id="fx_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.add_job(ml_retrain_job, CronTrigger(day_of_week="sun", hour=1), id="ml_retrain")
_scheduler.start() _scheduler.start()

51
backend/scripts/backup.sh Executable file
View file

@ -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"

View file

@ -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 "$@"

56
backend/scripts/restore.sh Executable file
View file

@ -0,0 +1,56 @@
#!/bin/bash
# restore.sh — decrypt and restore a backup
# Usage:
# docker compose exec backend bash scripts/restore.sh <backup_file>
# 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 <backup_file.sql.gz.gpg>"
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"

View file

@ -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()

54
backend/scripts/rotate_keys.sh Executable file
View file

@ -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"

32
frontend/src/api/admin.ts Normal file
View file

@ -0,0 +1,32 @@
import { api } from "./client";
export interface BackupFile {
filename: string;
size_bytes: number;
created_at: string;
}
export async function listBackups(): Promise<BackupFile[]> {
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<void> {
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;
}

View file

@ -61,11 +61,23 @@ export interface PricePoint {
volume: number | null; volume: number | null;
} }
export interface PerformanceMetrics {
twrr: number | null;
total_return: number;
total_return_pct: number;
currency: string;
}
export async function getPortfolio(): Promise<PortfolioSummary> { export async function getPortfolio(): Promise<PortfolioSummary> {
const r = await api.get("/investments/portfolio"); const r = await api.get("/investments/portfolio");
return r.data; return r.data;
} }
export async function getPerformance(): Promise<PerformanceMetrics> {
const r = await api.get("/investments/performance");
return r.data;
}
export async function searchAssets(q: string): Promise<AssetSearchResult[]> { export async function searchAssets(q: string): Promise<AssetSearchResult[]> {
const r = await api.get("/assets/search", { params: { q } }); const r = await api.get("/assets/search", { params: { q } });
return r.data; return r.data;
@ -87,6 +99,11 @@ export async function createHolding(data: {
return r.data; return r.data;
} }
export async function updateHolding(id: string, data: { quantity: number; avg_cost_basis: number }): Promise<HoldingResponse> {
const r = await api.patch(`/investments/holdings/${id}`, data);
return r.data;
}
export async function deleteHolding(id: string): Promise<void> { export async function deleteHolding(id: string): Promise<void> {
await api.delete(`/investments/holdings/${id}`); await api.delete(`/investments/holdings/${id}`);
} }
@ -109,3 +126,33 @@ export async function getHoldingTransactions(holdingId: string): Promise<Investm
const r = await api.get(`/investments/holdings/${holdingId}/transactions`); const r = await api.get(`/investments/holdings/${holdingId}/transactions`);
return r.data; return r.data;
} }
export interface CapitalGainsDisposal {
date: string;
symbol: string;
asset_name: string;
quantity: number;
proceeds: number;
cost: number;
gain: number;
currency: string;
}
export interface TaxYearSummary {
tax_year: string;
disposals: CapitalGainsDisposal[];
total_proceeds: number;
total_cost: number;
total_gain: number;
currency: string;
}
export interface CapitalGainsReport {
tax_years: TaxYearSummary[];
currency: string;
}
export async function getCapitalGains(): Promise<CapitalGainsReport> {
const r = await api.get("/investments/capital-gains");
return r.data;
}

View file

@ -122,6 +122,25 @@ export async function getSpendingTrends(months = 6): Promise<SpendingTrendsRepor
return r.data; return r.data;
} }
export interface SavingsRatePoint {
month: string;
income: number;
expenses: number;
savings: number;
savings_rate: number;
}
export interface SavingsRateReport {
points: SavingsRatePoint[];
avg_savings_rate: number;
currency: string;
}
export async function getSavingsRate(months = 12): Promise<SavingsRateReport> {
const r = await api.get("/reports/savings-rate", { params: { months } });
return r.data;
}
export interface BalanceSheetAccount { export interface BalanceSheetAccount {
id: string; id: string;
name: string; name: string;

View file

@ -61,6 +61,7 @@ export interface TransactionFilters {
date_from?: string; date_from?: string;
date_to?: string; date_to?: string;
search?: string; search?: string;
is_recurring?: boolean;
page?: number; page?: number;
page_size?: number; page_size?: number;
} }

View file

@ -1,7 +1,7 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { logout } from "@/api/auth"; import { logout } from "@/api/auth";
import { LogOut, User } from "lucide-react"; import { LogOut, User, Coins } from "lucide-react";
import ThemePicker from "./ThemePicker"; import ThemePicker from "./ThemePicker";
export default function TopBar() { export default function TopBar() {
@ -18,10 +18,16 @@ export default function TopBar() {
} }
return ( return (
<header className="h-16 border-b border-border bg-card flex items-center justify-end px-4 md:px-6 gap-3 shrink-0"> <header className="h-16 border-b border-border bg-card flex items-center px-4 md:px-6 gap-3 shrink-0">
{/* Logo visible on mobile only (sidebar is hidden) */}
<div className="flex items-center gap-2 lg:hidden mr-auto">
<Coins className="w-6 h-6 text-primary shrink-0" />
<span className="font-semibold text-base">MyMidas</span>
</div>
<div className="ml-auto flex items-center gap-3">
<ThemePicker /> <ThemePicker />
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary text-sm border border-border"> <div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary text-sm border border-border">
<User className="w-4 h-4 text-muted-foreground" /> <User className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium">{displayName ?? "User"}</span> <span className="text-foreground font-medium">{displayName ?? "User"}</span>
</div> </div>
@ -33,6 +39,7 @@ export default function TopBar() {
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
</button> </button>
</div>
</header> </header>
); );
} }

View file

@ -99,7 +99,7 @@ export default function AccountList() {
</div> </div>
{nw && ( {nw && (
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[ {[
{ label: "Total Assets", value: nw.total_assets, positive: true }, { label: "Total Assets", value: nw.total_assets, positive: true },
{ label: "Total Liabilities", value: nw.total_liabilities, positive: nw.total_liabilities === 0 }, { label: "Total Liabilities", value: nw.total_liabilities, positive: nw.total_liabilities === 0 },
@ -253,7 +253,7 @@ function AccountGroup({
)} )}
</div> </div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">
<button <button
onClick={() => onEdit(account)} onClick={() => onEdit(account)}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary" className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"

View file

@ -1,8 +1,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { X, Search, Loader2 } from "lucide-react"; import { X, Search, Loader2 } from "lucide-react";
import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments"; import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments";
import { useUiStore } from "@/store/uiStore";
import { format } from "date-fns"; import { format } from "date-fns";
const COMMON_CURRENCIES = ["GBP", "USD", "EUR", "JPY", "CAD", "AUD", "CHF"];
interface Account { id: string; name: string; type: string; } interface Account { id: string; name: string; type: string; }
interface Props { interface Props {
@ -12,6 +15,7 @@ interface Props {
} }
export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) { export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) {
const baseCurrency = useUiStore(s => s.currency);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [results, setResults] = useState<AssetSearchResult[]>([]); const [results, setResults] = useState<AssetSearchResult[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
@ -27,6 +31,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
account_id: investAccounts[0]?.id ?? "", account_id: investAccounts[0]?.id ?? "",
quantity: "", quantity: "",
price: "", price: "",
currency: baseCurrency,
fees: "0", fees: "0",
date: format(new Date(), "yyyy-MM-dd"), date: format(new Date(), "yyyy-MM-dd"),
}); });
@ -58,7 +63,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
asset_id: selected.id, asset_id: selected.id,
quantity: qty, quantity: qty,
avg_cost_basis: price, avg_cost_basis: price,
currency: selected.currency, currency: form.currency,
}); });
await addInvestmentTransaction({ await addInvestmentTransaction({
holding_id: holding.id, holding_id: holding.id,
@ -66,7 +71,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
quantity: qty, quantity: qty,
price: price, price: price,
fees: parseFloat(form.fees) || 0, fees: parseFloat(form.fees) || 0,
currency: selected.currency, currency: form.currency,
date: form.date, date: form.date,
}); });
onSuccess(); onSuccess();
@ -156,6 +161,14 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
</div> </div>
<div> <div>
<label className="text-xs font-medium block mb-1">Price paid *</label> <label className="text-xs font-medium block mb-1">Price paid *</label>
<div className="flex gap-1">
<select
value={form.currency}
onChange={(e) => setForm(f => ({ ...f, currency: e.target.value }))}
className="rounded-md border border-input bg-background px-1.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
>
{COMMON_CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<input <input
type="number" min="0" step="any" type="number" min="0" step="any"
value={form.price} value={form.price}
@ -164,6 +177,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
placeholder="150.00" placeholder="150.00"
/> />
</div> </div>
</div>
<div> <div>
<label className="text-xs font-medium block mb-1">Fees</label> <label className="text-xs font-medium block mb-1">Fees</label>
<input <input
@ -176,6 +190,12 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
</div> </div>
</div> </div>
{selected && form.currency !== selected.currency && (
<p className="text-xs text-muted-foreground bg-secondary/50 rounded-md px-3 py-2">
Price in <strong>{form.currency}</strong> asset trades in <strong>{selected.currency}</strong>. Current values will be converted using live exchange rates.
</p>
)}
<div> <div>
<label className="text-sm font-medium block mb-1.5">Purchase date *</label> <label className="text-sm font-medium block mb-1.5">Purchase date *</label>
<input <input

View file

@ -2,12 +2,14 @@ import { useParams, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getPriceHistory, getPortfolio } from "@/api/investments"; import { getPriceHistory, getPortfolio } from "@/api/investments";
import { formatCurrency } from "@/utils/currency"; import { formatCurrency } from "@/utils/currency";
import { useUiStore } from "@/store/uiStore";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react"; import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react";
import Plot from "react-plotly.js"; import Plot from "react-plotly.js";
export default function AssetDetail() { export default function AssetDetail() {
const { assetId } = useParams<{ assetId: string }>(); const { assetId } = useParams<{ assetId: string }>();
const baseCurrency = useUiStore(s => s.currency);
const { data: portfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio }); const { data: portfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio });
const holding = portfolio?.holdings.find(h => h.asset_id === assetId); const holding = portfolio?.holdings.find(h => h.asset_id === assetId);
@ -46,10 +48,10 @@ export default function AssetDetail() {
{/* Price header */} {/* Price header */}
{latestPrice != null && ( {latestPrice != null && (
<div className="flex items-end gap-4"> <div className="flex items-end gap-4">
<p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? "GBP")}</p> <p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? baseCurrency)}</p>
<div className={cn("flex items-center gap-1 pb-1 text-sm font-medium", isUp ? "text-success" : "text-destructive")}> <div className={cn("flex items-center gap-1 pb-1 text-sm font-medium", isUp ? "text-success" : "text-destructive")}>
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />} {isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? "GBP")} ({changePct.toFixed(2)}%) {isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? baseCurrency)} ({changePct.toFixed(2)}%)
</div> </div>
</div> </div>
)} )}

View file

@ -1,10 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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 { getAccounts } from "@/api/accounts";
import { formatCurrency } from "@/utils/currency"; import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn"; 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 AddHoldingModal from "./AddHoldingModal";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@ -13,9 +14,127 @@ const COLORS = [
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444", "#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
]; ];
function ConfirmDeleteDialog({ holding, onConfirm, onCancel, isPending }: {
holding: HoldingResponse;
onConfirm: () => void;
onCancel: () => void;
isPending: boolean;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-sm shadow-xl p-6">
<div className="flex items-start gap-4 mb-5">
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
<div>
<h3 className="font-semibold text-base">Delete holding?</h3>
<p className="text-sm text-muted-foreground mt-1">
This will permanently remove <span className="font-medium text-foreground">{holding.symbol}</span> ({holding.asset_name}) and all its transaction history. This cannot be undone.
</p>
</div>
</div>
<div className="flex gap-3">
<button
onClick={onCancel}
disabled={isPending}
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={isPending}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors disabled:opacity-50"
>
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{isPending ? "Deleting…" : "Delete holding"}
</button>
</div>
</div>
</div>
);
}
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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-sm shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border">
<div>
<h2 className="font-semibold text-lg">Edit {holding.symbol}</h2>
<p className="text-xs text-muted-foreground">{holding.asset_name}</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-4">
<div>
<label className="text-sm font-medium block mb-1.5">Quantity</label>
<input
type="number" min="0" step="any"
value={quantity}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Avg cost basis ({holding.currency})</label>
<input
type="number" min="0" step="any"
value={avgCost}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-1">
<button onClick={onClose} className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !quantity || !avgCost}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{mutation.isPending ? "Saving…" : "Save changes"}
</button>
</div>
</div>
</div>
</div>
);
}
export default function PortfolioPage() { export default function PortfolioPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [editHolding, setEditHolding] = useState<HoldingResponse | null>(null);
const [deleteTarget, setDeleteTarget] = useState<HoldingResponse | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const { data: portfolio, isLoading } = useQuery({ const { data: portfolio, isLoading } = useQuery({
queryKey: ["portfolio"], queryKey: ["portfolio"],
@ -27,7 +146,14 @@ export default function PortfolioPage() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteHolding, 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 const treemapData = portfolio?.holdings
@ -75,7 +201,7 @@ export default function PortfolioPage() {
</div> </div>
)} )}
{/* Treemap */} {/* Allocation bar */}
{treemapData.length > 1 && ( {treemapData.length > 1 && (
<div className="bg-card border border-border rounded-xl p-4"> <div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Allocation</p> <p className="text-sm font-medium mb-3">Allocation</p>
@ -127,7 +253,7 @@ export default function PortfolioPage() {
<th className="text-right px-4 py-3">Value</th> <th className="text-right px-4 py-3">Value</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">Gain / Loss</th> <th className="text-right px-4 py-3 hidden lg:table-cell">Gain / Loss</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">24h</th> <th className="text-right px-4 py-3 hidden lg:table-cell">24h</th>
<th className="w-16"></th> <th className="w-24"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -173,16 +299,25 @@ export default function PortfolioPage() {
) : "—"} ) : "—"}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center justify-end gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
<Link <Link
to={`/investments/${h.asset_id}`} to={`/investments/${h.asset_id}`}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary" className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
title="View chart"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</Link> </Link>
<button <button
onClick={() => deleteMutation.mutate(h.id)} onClick={() => setEditHolding(h)}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
title="Edit"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => { setDeleteTarget(h); setDeleteError(null); }}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10" className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Delete"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
@ -196,6 +331,13 @@ export default function PortfolioPage() {
</div> </div>
)} )}
{deleteError && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg px-4 py-3 text-sm text-destructive flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
{deleteError}
</div>
)}
{showAdd && ( {showAdd && (
<AddHoldingModal <AddHoldingModal
accounts={accounts} accounts={accounts}
@ -203,6 +345,23 @@ export default function PortfolioPage() {
onSuccess={() => { qc.invalidateQueries({ queryKey: ["portfolio"] }); setShowAdd(false); }} onSuccess={() => { qc.invalidateQueries({ queryKey: ["portfolio"] }); setShowAdd(false); }}
/> />
)} )}
{editHolding && (
<EditHoldingModal
holding={editHolding}
onClose={() => setEditHolding(null)}
onSuccess={() => setEditHolding(null)}
/>
)}
{deleteTarget && (
<ConfirmDeleteDialog
holding={deleteTarget}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
onCancel={() => { setDeleteTarget(null); setDeleteError(null); }}
isPending={deleteMutation.isPending}
/>
)}
</div> </div>
); );
} }

View file

@ -3,22 +3,30 @@ import { useQuery } from "@tanstack/react-query";
import { import {
getNetWorthReport, getNetWorthReport,
getIncomeExpenseReport, getIncomeExpenseReport,
getCashFlowReport,
getCategoryBreakdown, getCategoryBreakdown,
getBudgetVsActual, getBudgetVsActual,
getSpendingTrends, getSpendingTrends,
getSavingsRate,
getBalanceSheet, getBalanceSheet,
} from "@/api/reports"; } from "@/api/reports";
import { getPortfolio, getPerformance, getCapitalGains } from "@/api/investments";
import type { TaxYearSummary } from "@/api/investments";
import type { BalanceSheetGroup } from "@/api/reports"; import type { BalanceSheetGroup } from "@/api/reports";
import { formatCurrency } from "@/utils/currency"; import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { import {
AreaChart, Area, BarChart, Bar, AreaChart, Area, BarChart, Bar, ComposedChart, Line,
PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Legend Tooltip, ResponsiveContainer, Legend
} from "recharts"; } 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]; type Tab = typeof TABS[number];
const COLORS = [ const COLORS = [
@ -70,7 +78,6 @@ function BalanceSheetTab() {
const noAccounts = data.asset_groups.length === 0 && data.liability_groups.length === 0; const noAccounts = data.asset_groups.length === 0 && data.liability_groups.length === 0;
if (noAccounts) return <EmptyChart message="No accounts found" />; if (noAccounts) return <EmptyChart message="No accounts found" />;
// Build stacked bar data: one bar for assets, one for liabilities
const assetBarData = data.asset_groups.map((g, i) => ({ const assetBarData = data.asset_groups.map((g, i) => ({
name: g.label, name: g.label,
value: Number(g.subtotal), value: Number(g.subtotal),
@ -81,13 +88,10 @@ function BalanceSheetTab() {
value: Number(g.subtotal), value: Number(g.subtotal),
color: LIABILITY_COLORS[i % LIABILITY_COLORS.length], 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); const maxVal = Math.max(Number(data.total_assets), Number(data.total_liabilities), 1);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Summary KPIs */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-xl p-4"> <div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Assets</p> <p className="text-xs text-muted-foreground mb-1">Total Assets</p>
@ -105,11 +109,8 @@ function BalanceSheetTab() {
</div> </div>
</div> </div>
{/* Visual proportion bars */}
<div className="bg-card border border-border rounded-xl p-5 space-y-4"> <div className="bg-card border border-border rounded-xl p-5 space-y-4">
<p className="text-sm font-medium">Asset & Liability Composition</p> <p className="text-sm font-medium">Asset & Liability Composition</p>
{/* Assets bar */}
{assetBarData.length > 0 && ( {assetBarData.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
@ -120,16 +121,9 @@ function BalanceSheetTab() {
</div> </div>
<div className="flex h-7 rounded-lg overflow-hidden gap-px bg-secondary"> <div className="flex h-7 rounded-lg overflow-hidden gap-px bg-secondary">
{assetBarData.map((seg) => ( {assetBarData.map((seg) => (
<div <div key={seg.name} className="transition-all duration-500"
key={seg.name} style={{ width: `${(seg.value / maxVal) * 100}%`, background: seg.color, minWidth: seg.value > 0 ? "2px" : "0" }}
className="transition-all duration-500" title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} />
style={{
width: `${(seg.value / maxVal) * 100}%`,
background: seg.color,
minWidth: seg.value > 0 ? "2px" : "0",
}}
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`}
/>
))} ))}
</div> </div>
<div className="flex flex-wrap gap-x-4 gap-y-1"> <div className="flex flex-wrap gap-x-4 gap-y-1">
@ -142,8 +136,6 @@ function BalanceSheetTab() {
</div> </div>
</div> </div>
)} )}
{/* Liabilities bar */}
{liabilityBarData.length > 0 && ( {liabilityBarData.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
@ -154,16 +146,9 @@ function BalanceSheetTab() {
</div> </div>
<div className="flex h-7 rounded-lg overflow-hidden gap-px bg-secondary"> <div className="flex h-7 rounded-lg overflow-hidden gap-px bg-secondary">
{liabilityBarData.map((seg) => ( {liabilityBarData.map((seg) => (
<div <div key={seg.name} className="transition-all duration-500"
key={seg.name} style={{ width: `${(seg.value / maxVal) * 100}%`, background: seg.color, minWidth: seg.value > 0 ? "2px" : "0" }}
className="transition-all duration-500" title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} />
style={{
width: `${(seg.value / maxVal) * 100}%`,
background: seg.color,
minWidth: seg.value > 0 ? "2px" : "0",
}}
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`}
/>
))} ))}
</div> </div>
<div className="flex flex-wrap gap-x-4 gap-y-1"> <div className="flex flex-wrap gap-x-4 gap-y-1">
@ -178,9 +163,7 @@ function BalanceSheetTab() {
)} )}
</div> </div>
{/* Side-by-side account breakdown */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Assets */}
<div className="bg-card border border-border rounded-xl p-5"> <div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold flex items-center gap-2"> <h3 className="text-sm font-semibold flex items-center gap-2">
@ -205,7 +188,6 @@ function BalanceSheetTab() {
</div> </div>
</div> </div>
{/* Liabilities */}
<div className="bg-card border border-border rounded-xl p-5"> <div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold flex items-center gap-2"> <h3 className="text-sm font-semibold flex items-center gap-2">
@ -320,40 +302,259 @@ function IncomeExpenseTab() {
); );
} }
function CashFlowTab() {
const { data, isLoading } = useQuery({
queryKey: ["report-cash-flow"],
queryFn: () => getCashFlowReport(),
});
if (isLoading) return <ChartSkeleton />;
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 (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Inflow</p>
<p className="text-xl font-bold tabular-nums text-success">{formatCurrency(Number(data.total_inflow), data.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Outflow</p>
<p className="text-xl font-bold tabular-nums text-destructive">{formatCurrency(Number(data.total_outflow), data.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Net</p>
<p className={cn("text-xl font-bold tabular-nums", Number(data.total_inflow) - Number(data.total_outflow) >= 0 ? "text-success" : "text-destructive")}>
{formatCurrency(Number(data.total_inflow) - Number(data.total_outflow), data.currency)}
</p>
</div>
</div>
{chartData.length === 0 ? <EmptyChart /> : (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Daily Cash Flow Last 30 Days</p>
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis yAxisId="bars" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} />
<YAxis yAxisId="line" orientation="right" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
<Bar yAxisId="bars" dataKey="inflow" fill="#22c55e" name="Inflow" radius={[2, 2, 0, 0]} />
<Bar yAxisId="bars" dataKey="outflow" fill="#ef4444" name="Outflow" radius={[2, 2, 0, 0]} />
<Line yAxisId="line" type="monotone" dataKey="balance" stroke="#6366f1" strokeWidth={2} dot={false} name="Running Balance" />
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
function SavingsRateTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-savings-rate"], queryFn: () => getSavingsRate(12) });
if (isLoading) return <ChartSkeleton />;
if (!data || data.points.length === 0) return <EmptyChart message="No income/expense data yet" />;
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 (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Avg Savings Rate</p>
<p className={cn("text-xl font-bold tabular-nums", avg >= 20 ? "text-success" : avg >= 0 ? "text-warning" : "text-destructive")}>
{avg >= 0 ? "" : ""}{avg.toFixed(1)}%
</p>
<p className="text-xs text-muted-foreground mt-1">over {data.points.length} months</p>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex items-center gap-3">
<PiggyBank className="w-8 h-8 text-primary opacity-60" />
<div>
<p className="text-xs text-muted-foreground">Best Month</p>
<p className="text-sm font-semibold">
{chartData.reduce((best, p) => p.rate > best.rate ? p : best, chartData[0])?.month ?? "—"}
</p>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex items-center gap-3">
<div>
<p className="text-xs text-muted-foreground">Latest Month Savings</p>
<p className="text-sm font-semibold tabular-nums">
{formatCurrency(chartData[chartData.length - 1]?.savings ?? 0, data.currency)}
</p>
</div>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Savings Rate by Month</p>
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis yAxisId="bars" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<YAxis yAxisId="rate" orientation="right" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `${v}%`} />
<Tooltip formatter={(v: number, name: string) => name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} />
<Legend />
<Bar yAxisId="bars" dataKey="income" fill="#22c55e" name="Income" radius={[2, 2, 0, 0]} />
<Bar yAxisId="bars" dataKey="expenses" fill="#ef4444" name="Expenses" radius={[2, 2, 0, 0]} />
<Line yAxisId="rate" type="monotone" dataKey="rate" stroke="#6366f1" strokeWidth={2.5} dot={{ r: 3 }} name="Savings Rate %" />
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border bg-secondary/30">
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
<th className="text-left px-4 py-3">Month</th>
<th className="text-right px-4 py-3">Income</th>
<th className="text-right px-4 py-3">Expenses</th>
<th className="text-right px-4 py-3">Saved</th>
<th className="text-right px-4 py-3">Rate</th>
</tr>
</thead>
<tbody>
{[...chartData].reverse().map((row) => (
<tr key={row.month} className="border-b border-border/50 hover:bg-secondary/20">
<td className="px-4 py-2.5 font-medium">{row.month}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-success">{formatCurrency(row.income, data.currency)}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-destructive">{formatCurrency(row.expenses, data.currency)}</td>
<td className={cn("px-4 py-2.5 text-right tabular-nums", row.savings >= 0 ? "text-success" : "text-destructive")}>
{formatCurrency(row.savings, data.currency)}
</td>
<td className={cn("px-4 py-2.5 text-right tabular-nums font-semibold", row.rate >= 20 ? "text-success" : row.rate >= 0 ? "text-foreground" : "text-destructive")}>
{row.rate.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function CategoriesTab() { function CategoriesTab() {
const [drillCategory, setDrillCategory] = useState<string | null>(null);
const { data, isLoading } = useQuery({ queryKey: ["report-categories"], queryFn: () => getCategoryBreakdown() }); const { data, isLoading } = useQuery({ queryKey: ["report-categories"], queryFn: () => getCategoryBreakdown() });
if (isLoading) return <ChartSkeleton />; if (isLoading) return <ChartSkeleton />;
if (!data) return null; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-card border border-border rounded-xl p-4"> <div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-1">Expense Breakdown This Month</p> <p className="text-sm font-medium mb-1">Expense Breakdown This Month</p>
<p className="text-xs text-muted-foreground mb-4">Total: {formatCurrency(Number(data.total), data.currency)}</p> <p className="text-xs text-muted-foreground mb-4">Total: {formatCurrency(Number(data.total), data.currency)}
{drillCategory && <span> · <button onClick={() => setDrillCategory(null)} className="text-primary underline">clear filter</button></span>}
</p>
{pieData.length === 0 ? <EmptyChart /> : ( {pieData.length === 0 ? <EmptyChart /> : (
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start flex-wrap">
<ResponsiveContainer width={220} height={220}> <ResponsiveContainer width={220} height={220}>
<PieChart> <PieChart>
<Pie data={pieData} cx="50%" cy="50%" innerRadius={60} outerRadius={90} dataKey="value" paddingAngle={2}> <Pie
{pieData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)} data={pieData}
cx="50%" cy="50%"
innerRadius={60} outerRadius={90}
dataKey="value"
paddingAngle={2}
onClick={(entry) => setDrillCategory(drillCategory === entry.name ? null : entry.name)}
style={{ cursor: "pointer" }}
>
{pieData.map((_, i) => (
<Cell
key={i}
fill={COLORS[i % COLORS.length]}
opacity={activeIndex === -1 || activeIndex === i ? 1 : 0.35}
stroke={activeIndex === i ? "var(--foreground)" : "none"}
strokeWidth={activeIndex === i ? 2 : 0}
/>
))}
</Pie> </Pie>
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} /> <Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2 min-w-48">
{data.items.slice(0, 10).map((item, i) => ( {data.items.slice(0, 10).map((item, i) => (
<div key={i} className="flex items-center gap-2 text-sm"> <button
key={i}
onClick={() => setDrillCategory(drillCategory === item.category_name ? null : item.category_name)}
className={cn(
"w-full flex items-center gap-2 text-sm rounded-lg px-2 py-1 transition-colors text-left",
drillCategory === item.category_name ? "bg-secondary" : "hover:bg-secondary/50"
)}
>
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: COLORS[i % COLORS.length] }} /> <div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
<span className="flex-1 truncate text-muted-foreground">{item.category_name}</span> <span className="flex-1 truncate text-muted-foreground">{item.category_name}</span>
<span className="font-medium tabular-nums">{formatCurrency(Number(item.amount), data.currency)}</span> <span className="font-medium tabular-nums">{formatCurrency(Number(item.amount), data.currency)}</span>
<span className="text-xs text-muted-foreground w-10 text-right">{item.percent}%</span> <span className="text-xs text-muted-foreground w-10 text-right">{item.percent}%</span>
</div> </button>
))} ))}
</div> </div>
</div> </div>
)} )}
</div> </div>
{drillCategory && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Transactions in <span className="text-primary">{drillCategory}</span> this month</p>
<DrillDownTransactions categoryId={data.items.find(i => i.category_name === drillCategory)?.category_id ?? null} currency={data.currency} dateFrom={data.date_from} dateTo={data.date_to} />
</div>
)}
</div>
);
}
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 <p className="text-sm text-muted-foreground">Uncategorised transactions cannot be filtered here.</p>;
if (isLoading) return <div className="h-20 animate-pulse bg-secondary/30 rounded-lg" />;
if (!data?.items.length) return <p className="text-sm text-muted-foreground py-4 text-center">No transactions found.</p>;
return (
<div className="space-y-1">
{data.items.map((txn) => (
<div key={txn.id} className="flex items-center justify-between py-1.5 border-b border-border/40 last:border-0 text-sm">
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{txn.description}</p>
<p className="text-xs text-muted-foreground">{txn.date}</p>
</div>
<span className="tabular-nums font-semibold text-destructive ml-4 shrink-0">
{formatCurrency(Math.abs(Number(txn.amount)), currency)}
</span>
</div>
))}
</div> </div>
); );
} }
@ -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 <ChartSkeleton />;
if (!portfolio || !perf) return null;
const noHoldings = portfolio.holdings.length === 0;
if (noHoldings) return <EmptyChart message="No investment holdings yet" />;
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Portfolio Value</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(portfolio.total_value), perf.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Cost</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(portfolio.total_cost), perf.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Return</p>
<p className={cn("text-xl font-bold tabular-nums", Number(perf.total_return) >= 0 ? "text-success" : "text-destructive")}>
{Number(perf.total_return) >= 0 ? "+" : ""}{formatCurrency(Number(perf.total_return), perf.currency)}
</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Return %</p>
<p className={cn("text-xl font-bold tabular-nums", Number(perf.total_return_pct) >= 0 ? "text-success" : "text-destructive")}>
{Number(perf.total_return_pct) >= 0 ? "+" : ""}{Number(perf.total_return_pct).toFixed(2)}%
</p>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Holdings Value</p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={holdingsData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis type="number" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" width={60} />
<Tooltip formatter={(v: number) => formatCurrency(v, perf.currency)} />
<Bar dataKey="value" name="Current Value" radius={[0, 3, 3, 0]}>
{holdingsData.map((entry, i) => (
<Cell key={i} fill={entry.gain >= 0 ? "#22c55e" : "#ef4444"} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border bg-secondary/30">
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
<th className="text-left px-4 py-3">Asset</th>
<th className="text-right px-4 py-3">Qty</th>
<th className="text-right px-4 py-3">Avg Cost</th>
<th className="text-right px-4 py-3">Current Price</th>
<th className="text-right px-4 py-3">Value</th>
<th className="text-right px-4 py-3">Gain / Loss</th>
</tr>
</thead>
<tbody>
{portfolio.holdings.map((h) => (
<tr key={h.id} className="border-b border-border/50 hover:bg-secondary/20">
<td className="px-4 py-3">
<p className="font-medium">{h.symbol}</p>
<p className="text-xs text-muted-foreground truncate max-w-32">{h.asset_name}</p>
</td>
<td className="px-4 py-3 text-right tabular-nums">{Number(h.quantity).toFixed(4)}</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(h.avg_cost_basis), h.currency)}</td>
<td className="px-4 py-3 text-right tabular-nums">{h.current_price != null ? formatCurrency(Number(h.current_price), h.currency) : "—"}</td>
<td className="px-4 py-3 text-right tabular-nums font-semibold">{h.current_value != null ? formatCurrency(Number(h.current_value), h.currency) : "—"}</td>
<td className={cn("px-4 py-3 text-right tabular-nums font-semibold", h.unrealised_gain != null && Number(h.unrealised_gain) >= 0 ? "text-success" : "text-destructive")}>
{h.unrealised_gain != null ? (
<>
{Number(h.unrealised_gain) >= 0 ? "+" : ""}{formatCurrency(Number(h.unrealised_gain), h.currency)}
<span className="text-xs font-normal ml-1">({Number(h.unrealised_gain_pct ?? 0).toFixed(1)}%)</span>
</>
) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function CapitalGainsTab() {
const { data, isLoading } = useQuery({ queryKey: ["capital-gains"], queryFn: getCapitalGains });
const [selectedYear, setSelectedYear] = useState<string | null>(null);
if (isLoading) return <ChartSkeleton />;
if (!data || data.tax_years.length === 0) {
return <EmptyChart message="No disposals recorded yet" />;
}
const activeYear: TaxYearSummary = data.tax_years.find(y => y.tax_year === selectedYear) ?? data.tax_years[0];
return (
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
<p className="text-sm text-muted-foreground">UK tax year:</p>
<div className="flex gap-1.5 flex-wrap">
{data.tax_years.map(y => (
<button
key={y.tax_year}
onClick={() => setSelectedYear(y.tax_year)}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium border transition-colors",
(selectedYear ?? data.tax_years[0].tax_year) === y.tax_year
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground"
)}
>
{y.tax_year}
</button>
))}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Proceeds</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(activeYear.total_proceeds), activeYear.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Cost</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(activeYear.total_cost), activeYear.currency)}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Net Gain / Loss</p>
<p className={cn("text-xl font-bold tabular-nums", Number(activeYear.total_gain) >= 0 ? "text-success" : "text-destructive")}>
{Number(activeYear.total_gain) >= 0 ? "+" : ""}{formatCurrency(Number(activeYear.total_gain), activeYear.currency)}
</p>
</div>
</div>
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border bg-secondary/30">
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
<th className="text-left px-4 py-3">Date</th>
<th className="text-left px-4 py-3">Asset</th>
<th className="text-right px-4 py-3">Qty</th>
<th className="text-right px-4 py-3">Proceeds</th>
<th className="text-right px-4 py-3">Cost</th>
<th className="text-right px-4 py-3">Gain / Loss</th>
</tr>
</thead>
<tbody>
{activeYear.disposals.map((d, i) => {
const gain = Number(d.gain);
return (
<tr key={i} className="border-b border-border/50 hover:bg-secondary/20">
<td className="px-4 py-3 tabular-nums text-muted-foreground">{d.date}</td>
<td className="px-4 py-3">
<p className="font-medium">{d.symbol}</p>
<p className="text-xs text-muted-foreground truncate max-w-40">{d.asset_name}</p>
</td>
<td className="px-4 py-3 text-right tabular-nums">{Number(d.quantity).toFixed(4)}</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(d.proceeds), d.currency)}</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(d.cost), d.currency)}</td>
<td className={cn("px-4 py-3 text-right tabular-nums font-semibold", gain >= 0 ? "text-success" : "text-destructive")}>
{gain >= 0 ? "+" : ""}{formatCurrency(gain, d.currency)}
</td>
</tr>
);
})}
</tbody>
<tfoot className="border-t border-border bg-secondary/20">
<tr className="text-sm font-semibold">
<td colSpan={3} className="px-4 py-3 text-muted-foreground">Total ({activeYear.disposals.length} disposal{activeYear.disposals.length !== 1 ? "s" : ""})</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(activeYear.total_proceeds), activeYear.currency)}</td>
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(activeYear.total_cost), activeYear.currency)}</td>
<td className={cn("px-4 py-3 text-right tabular-nums", Number(activeYear.total_gain) >= 0 ? "text-success" : "text-destructive")}>
{Number(activeYear.total_gain) >= 0 ? "+" : ""}{formatCurrency(Number(activeYear.total_gain), activeYear.currency)}
</td>
</tr>
</tfoot>
</table>
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
);
}
function ChartSkeleton() { function ChartSkeleton() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -478,9 +880,13 @@ export default function ReportsPage() {
{activeTab === "Balance Sheet" && <BalanceSheetTab />} {activeTab === "Balance Sheet" && <BalanceSheetTab />}
{activeTab === "Net Worth" && <NetWorthTab />} {activeTab === "Net Worth" && <NetWorthTab />}
{activeTab === "Income vs Expense" && <IncomeExpenseTab />} {activeTab === "Income vs Expense" && <IncomeExpenseTab />}
{activeTab === "Cash Flow" && <CashFlowTab />}
{activeTab === "Savings Rate" && <SavingsRateTab />}
{activeTab === "Categories" && <CategoriesTab />} {activeTab === "Categories" && <CategoriesTab />}
{activeTab === "Budget vs Actual" && <BudgetVsActualTab />} {activeTab === "Budget vs Actual" && <BudgetVsActualTab />}
{activeTab === "Spending Trends" && <SpendingTrendsTab />} {activeTab === "Spending Trends" && <SpendingTrendsTab />}
{activeTab === "Investments" && <InvestmentsTab />}
{activeTab === "Capital Gains" && <CapitalGainsTab />}
</div> </div>
</div> </div>
); );

View file

@ -7,12 +7,14 @@ import {
getTotpSetup, enableTotp, disableTotp, getTotpSetup, enableTotp, disableTotp,
changePassword, updateProfile, exportData, getMe, changePassword, updateProfile, exportData, getMe,
} from "@/api/auth"; } from "@/api/auth";
import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin";
import type { BackupFile } from "@/api/admin";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { format } from "date-fns"; import { format } from "date-fns";
import { import {
User, Shield, MonitorSmartphone, Download, User, Shield, MonitorSmartphone, Download, HardDrive,
Loader2, CheckCircle, Eye, EyeOff, Trash2, Loader2, CheckCircle, Eye, EyeOff, Trash2,
LogOut, QrCode, KeyRound, AlertTriangle, LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
const SECTIONS = [ const SECTIONS = [
@ -20,6 +22,7 @@ const SECTIONS = [
{ id: "security", label: "Security", icon: Shield }, { id: "security", label: "Security", icon: Shield },
{ id: "sessions", label: "Sessions", icon: MonitorSmartphone }, { id: "sessions", label: "Sessions", icon: MonitorSmartphone },
{ id: "data", label: "Data", icon: Download }, { id: "data", label: "Data", icon: Download },
{ id: "backups", label: "Backups", icon: HardDrive },
] as const; ] as const;
type Section = (typeof SECTIONS)[number]["id"]; type Section = (typeof SECTIONS)[number]["id"];
@ -60,6 +63,7 @@ export default function SettingsPage() {
{section === "security" && <SecuritySection />} {section === "security" && <SecuritySection />}
{section === "sessions" && <SessionsSection />} {section === "sessions" && <SessionsSection />}
{section === "data" && <DataSection />} {section === "data" && <DataSection />}
{section === "backups" && <BackupsSection />}
</div> </div>
</div> </div>
</div> </div>
@ -483,6 +487,167 @@ function SessionsSection() {
); );
} }
// ─── Backups ──────────────────────────────────────────────────────────────────
function BackupsSection() {
const qc = useQueryClient();
const [restoreTarget, setRestoreTarget] = useState<string | null>(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 (
<div className="space-y-4">
{/* Trigger */}
<div className={cardCls}>
<div className="flex items-center gap-2">
<HardDrive className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Database Backups</SectionTitle>
</div>
<p className="text-sm text-muted-foreground">
Backups run automatically at 3am daily. Each is GPG-encrypted with your backup passphrase and stored at <code className="text-xs bg-secondary px-1 py-0.5 rounded">/app/backups</code>.
</p>
{triggerMutation.isSuccess && <SuccessBanner message="Backup created successfully" />}
{triggerMutation.isError && <ErrorBanner message={(triggerMutation.error as any)?.response?.data?.detail ?? "Backup failed"} />}
<button
onClick={() => triggerMutation.mutate()}
disabled={triggerMutation.isPending}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{triggerMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{triggerMutation.isPending ? "Creating backup…" : "Backup now"}
</button>
</div>
{/* Backup list */}
<div className={cardCls}>
<div className="flex items-center justify-between">
<SectionTitle>Stored Backups</SectionTitle>
<button
onClick={() => qc.invalidateQueries({ queryKey: ["backups"] })}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Refresh list"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{restoreSuccess && <SuccessBanner message={restoreSuccess} />}
{restoreError && <ErrorBanner message={restoreError} />}
{isLoading ? (
<div className="space-y-2">
{[1,2,3].map(i => <div key={i} className="h-12 bg-secondary/30 rounded-lg animate-pulse" />)}
</div>
) : backups.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No backups yet click "Backup now" to create one.</p>
) : (
<div className="space-y-2">
{backups.map((b: BackupFile) => (
<div key={b.filename} className="flex items-center gap-3 p-3 rounded-lg border border-border bg-secondary/20">
<HardDrive className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium font-mono truncate">{b.filename}</p>
<p className="text-xs text-muted-foreground">
{formatSize(b.size_bytes)} · {format(new Date(b.created_at), "dd MMM yyyy HH:mm")}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => downloadBackup(b.filename)}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
title="Download"
>
<Download className="w-3 h-3" />
Download
</button>
<button
onClick={() => setRestoreTarget(b.filename)}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-destructive/40 text-destructive hover:bg-destructive/10 transition-colors"
title="Restore"
>
<RotateCcw className="w-3 h-3" />
Restore
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Restore confirmation dialog */}
{restoreTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-sm shadow-xl p-6">
<div className="flex items-start gap-4 mb-5">
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
<div>
<h3 className="font-semibold text-base">Restore this backup?</h3>
<p className="text-sm text-muted-foreground mt-1">
This will <strong>overwrite all current data</strong> with the contents of:
</p>
<p className="text-xs font-mono bg-secondary px-2 py-1 rounded mt-2 break-all">{restoreTarget}</p>
<p className="text-sm text-muted-foreground mt-2">This cannot be undone. Consider downloading your current backup first.</p>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setRestoreTarget(null)}
disabled={restoreMutation.isPending}
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => restoreMutation.mutate(restoreTarget)}
disabled={restoreMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{restoreMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{restoreMutation.isPending ? "Restoring…" : "Yes, restore"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ─── Data ───────────────────────────────────────────────────────────────────── // ─── Data ─────────────────────────────────────────────────────────────────────
function DataSection() { function DataSection() {

View file

@ -196,7 +196,7 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
<button <button
onClick={() => deleteMutation.mutate(att.id)} onClick={() => deleteMutation.mutate(att.id)}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="shrink-0 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all p-1 rounded" className="shrink-0 text-muted-foreground hover:text-destructive opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all p-1 rounded"
> >
{deleteMutation.isPending ? ( {deleteMutation.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />

View file

@ -5,10 +5,10 @@ import type { Transaction } from "@/api/transactions";
import { getAccounts } from "@/api/accounts"; import { getAccounts } from "@/api/accounts";
import { formatCurrency } from "@/utils/currency"; import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { format } from "date-fns"; import { format, startOfMonth, subMonths, startOfYear } from "date-fns";
import { import {
Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload, Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw
} from "lucide-react"; } from "lucide-react";
import TransactionFormModal from "./TransactionFormModal"; import TransactionFormModal from "./TransactionFormModal";
import TransactionDetailDrawer from "./TransactionDetailDrawer"; import TransactionDetailDrawer from "./TransactionDetailDrawer";
@ -28,6 +28,23 @@ const TYPE_ICONS = {
investment: TrendingUp, investment: TrendingUp,
}; };
type DatePreset = "this_month" | "last_3_months" | "this_year" | "all";
const DATE_PRESETS: { label: string; value: DatePreset }[] = [
{ label: "This month", value: "this_month" },
{ label: "Last 3 months", value: "last_3_months" },
{ label: "This year", value: "this_year" },
{ label: "All time", value: "all" },
];
function presetToDates(preset: DatePreset): { date_from?: string; date_to?: string } {
const today = new Date();
if (preset === "this_month") return { date_from: format(startOfMonth(today), "yyyy-MM-dd") };
if (preset === "last_3_months") return { date_from: format(startOfMonth(subMonths(today, 2)), "yyyy-MM-dd") };
if (preset === "this_year") return { date_from: format(startOfYear(today), "yyyy-MM-dd") };
return {};
}
export default function TransactionList() { export default function TransactionList() {
const qc = useQueryClient(); const qc = useQueryClient();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@ -35,15 +52,21 @@ export default function TransactionList() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filterType, setFilterType] = useState(""); const [filterType, setFilterType] = useState("");
const [filterAccount, setFilterAccount] = useState(""); const [filterAccount, setFilterAccount] = useState("");
const [datePreset, setDatePreset] = useState<DatePreset>("this_month");
const [recurringOnly, setRecurringOnly] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const dates = presetToDates(datePreset);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["transactions", { search, filterType, filterAccount, page }], queryKey: ["transactions", { search, filterType, filterAccount, datePreset, recurringOnly, page }],
queryFn: () => queryFn: () =>
getTransactions({ getTransactions({
search: search || undefined, search: search || undefined,
type: filterType || undefined, type: filterType || undefined,
account_id: filterAccount || undefined, account_id: filterAccount || undefined,
is_recurring: recurringOnly ? true : undefined,
...dates,
page, page,
page_size: 50, page_size: 50,
}), }),
@ -82,24 +105,54 @@ export default function TransactionList() {
{data ? `${data.total} transactions` : ""} {data ? `${data.total} transactions` : ""}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 shrink-0">
<Link <Link
to="/transactions/import" to="/transactions/import"
className="flex items-center gap-2 border border-border px-3 py-2 rounded-lg text-sm hover:bg-secondary transition-colors" className="flex items-center gap-2 border border-border px-3 py-2 rounded-lg text-sm hover:bg-secondary transition-colors"
> >
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
Import CSV <span className="hidden sm:inline">Import CSV</span>
</Link> </Link>
<button <button
onClick={() => setShowForm(true)} onClick={() => setShowForm(true)}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors" className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add <span className="hidden sm:inline">Add</span>
</button> </button>
</div> </div>
</div> </div>
{/* Date presets */}
<div className="flex gap-1 flex-wrap">
{DATE_PRESETS.map((p) => (
<button
key={p.value}
onClick={() => { setDatePreset(p.value); setPage(1); }}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors",
datePreset === p.value
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
{p.label}
</button>
))}
<button
onClick={() => { setRecurringOnly(!recurringOnly); setPage(1); }}
className={cn(
"px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors flex items-center gap-1.5 ml-auto",
recurringOnly
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
<RefreshCw className="w-3 h-3" />
Recurring only
</button>
</div>
{/* Filters */} {/* Filters */}
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-48"> <div className="relative flex-1 min-w-48">
@ -172,7 +225,8 @@ export default function TransactionList() {
onClick={() => setSelectedTxn(txn)} onClick={() => setSelectedTxn(txn)}
> >
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap"> <td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
{format(new Date(txn.date), "dd MMM yyyy")} <span className="hidden sm:inline">{format(new Date(txn.date), "dd MMM yyyy")}</span>
<span className="sm:hidden">{format(new Date(txn.date), "dd MMM")}</span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -205,7 +259,7 @@ export default function TransactionList() {
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}> <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<button <button
onClick={() => deleteMutation.mutate(txn.id)} onClick={() => deleteMutation.mutate(txn.id)}
className="p-1 rounded text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all" className="p-1 rounded text-muted-foreground hover:text-destructive opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>