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
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue