Complete Phase 3, Phase 5 polish and hardening

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

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

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

View file

@ -1,7 +1,9 @@
import uuid
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import select, delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
@ -9,6 +11,7 @@ from app.db.models.user import User
from app.schemas.investment import (
AssetSearch,
AssetPricePoint,
CapitalGainsReport,
HoldingCreate,
HoldingResponse,
InvestmentTxnCreate,
@ -29,7 +32,7 @@ async def get_portfolio(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await investment_service.get_portfolio(db, current_user.id)
return await investment_service.get_portfolio(db, current_user.id, current_user.base_currency)
@router.get("/investments/performance", response_model=PerformanceMetrics)
@ -37,7 +40,15 @@ async def get_performance(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await investment_service.get_performance(db, current_user.id)
return await investment_service.get_performance(db, current_user.id, current_user.base_currency)
@router.get("/investments/capital-gains", response_model=CapitalGainsReport)
async def get_capital_gains(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return await investment_service.get_capital_gains(db, current_user.id, current_user.base_currency)
# ── Holdings ───────────────────────────────────────────────────────────────
@ -61,6 +72,27 @@ async def create_holding(
return investment_service._holding_to_response(holding, asset)
@router.patch("/investments/holdings/{holding_id}", response_model=HoldingResponse)
async def update_holding(
holding_id: uuid.UUID,
quantity: Decimal = Body(...),
avg_cost_basis: Decimal = Body(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
holding = await investment_service.get_holding(db, current_user.id, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
holding.quantity = quantity
holding.avg_cost_basis = avg_cost_basis
await db.commit()
await db.refresh(holding)
from app.db.models.asset import Asset
result = await db.execute(select(Asset).where(Asset.id == holding.asset_id))
asset = result.scalar_one_or_none()
return investment_service._holding_to_response(holding, asset)
@router.delete("/investments/holdings/{holding_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_holding(
holding_id: uuid.UUID,
@ -70,6 +102,8 @@ async def delete_holding(
holding = await investment_service.get_holding(db, current_user.id, holding_id)
if not holding:
raise HTTPException(status_code=404, detail="Holding not found")
from app.db.models.investment_transaction import InvestmentTransaction
await db.execute(sa_delete(InvestmentTransaction).where(InvestmentTransaction.holding_id == holding_id))
await db.delete(holding)
await db.commit()