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:
parent
74e57a35c0
commit
fe4e69b9ad
40 changed files with 2079 additions and 127 deletions
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal 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`)
|
||||||
58
README.md
58
README.md
|
|
@ -80,7 +80,7 @@ Ten independent security layers:
|
||||||
3. **Security headers** — CSP, HSTS, X-Frame-Options DENY, X-Content-Type-Options, form-action, Permissions-Policy
|
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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
23
backend/alembic/versions/0002_refresh_token_hash.py
Normal file
23
backend/alembic/versions/0002_refresh_token_hash.py
Normal 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")
|
||||||
|
|
@ -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
152
backend/app/api/v1/admin.py
Normal 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))
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"}:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
||||||
25
backend/app/workers/backup.py
Normal file
25
backend/app/workers/backup.py
Normal 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))
|
||||||
|
|
@ -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
51
backend/scripts/backup.sh
Executable 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"
|
||||||
9
backend/scripts/entrypoint.sh
Normal file
9
backend/scripts/entrypoint.sh
Normal 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
56
backend/scripts/restore.sh
Executable 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"
|
||||||
195
backend/scripts/rotate_keys.py
Normal file
195
backend/scripts/rotate_keys.py
Normal 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
54
backend/scripts/rotate_keys.sh
Executable 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
32
frontend/src/api/admin.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,13 +161,22 @@ 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>
|
||||||
<input
|
<div className="flex gap-1">
|
||||||
type="number" min="0" step="any"
|
<select
|
||||||
value={form.price}
|
value={form.currency}
|
||||||
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
|
onChange={(e) => setForm(f => ({ ...f, currency: e.target.value }))}
|
||||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
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"
|
||||||
placeholder="150.00"
|
>
|
||||||
/>
|
{COMMON_CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number" min="0" step="any"
|
||||||
|
value={form.price}
|
||||||
|
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="150.00"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue