Add pensions module and integrate with tax report

Adds a full pensions feature: SIPP/workplace DC/LISA account metadata,
contribution recording with relief-at-source/net-pay/salary-sacrifice
gross calculations, state pension tracker, annual allowance monitor,
and LISA summary. Pension contributions feed into the tax report
(RAS gross totals, allowance used). Includes two Alembic migrations,
backend service/schema/API, and full frontend pensions page with
cards for allowance, state pension, LISA, and retirement projection.

Also fixes CSRF cookie secure flag (must be false for HTTP deployments)
and extends tax schemas/service to expose pension data in the report.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-28 09:59:01 +00:00
parent b30e8e577b
commit 1a2c8efd01
30 changed files with 3537 additions and 8 deletions

View file

@ -67,9 +67,9 @@ def _set_csrf_cookie(response: Response, token: str) -> None:
"csrf_token",
token,
httponly=False,
secure=True,
samesite="strict",
max_age=86400,
secure=False, # must be readable by JS; Secure breaks HTTP deployments
samesite="lax",
max_age=604800,
)

View file

@ -0,0 +1,306 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.account import Account
from app.db.models.pension import PensionMetadata
from app.db.models.user import User
from app.dependencies import get_current_user, get_db
from app.schemas.pension import (
AllowanceSummary,
LisaSummary,
PensionAccountResponse,
PensionContributionCreate,
PensionContributionResponse,
PensionContributionUpdate,
PensionMetadataCreate,
PensionMetadataResponse,
PensionMetadataUpdate,
RetirementProjection,
StatePensionCreate,
StatePensionResponse,
YtdSummary,
)
from app.services import pension_service
from app.core.security import decrypt_field
router = APIRouter(tags=["pensions"])
PENSION_ACCOUNT_TYPES = {"pension"}
def _current_tax_year() -> int:
today = date.today()
return today.year if (today.month > 4 or (today.month == 4 and today.day >= 6)) else today.year
async def _get_pension_account(
account_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession,
) -> Account:
result = await db.execute(
select(Account).where(
Account.id == account_id,
Account.user_id == user_id,
Account.type == "pension",
Account.deleted_at.is_(None),
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=404, detail="Pension account not found")
return account
async def _get_contribution_pension_id(
contribution_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession,
) -> uuid.UUID:
from app.db.models.pension import PensionContribution
result = await db.execute(
select(PensionContribution).where(
PensionContribution.id == contribution_id,
PensionContribution.user_id == user_id,
)
)
contrib = result.scalar_one_or_none()
if not contrib:
raise HTTPException(status_code=404, detail="Contribution not found")
return contrib.pension_id
# ---------------------------------------------------------------------------
# Pension accounts list + per-account summary
# ---------------------------------------------------------------------------
@router.get("/pensions", response_model=list[PensionAccountResponse])
async def list_pension_accounts(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Account).where(
Account.user_id == current_user.id,
Account.type == "pension",
Account.deleted_at.is_(None),
).order_by(Account.created_at)
)
accounts = result.scalars().all()
tax_year = _current_tax_year()
response = []
for acc in accounts:
meta = await pension_service.get_pension_metadata(db, acc.id, current_user.id)
ytd = await pension_service.get_ytd_summary(db, meta.id, current_user.id, tax_year) if meta else None
meta_response = None
if meta:
decoded = pension_service.decode_metadata(meta)
meta_response = PensionMetadataResponse(**decoded)
response.append(PensionAccountResponse(
account_id=acc.id,
account_name=decrypt_field(acc.name_enc) if acc.name_enc else "",
current_balance=acc.current_balance,
currency=acc.currency,
color=acc.color,
metadata=meta_response,
ytd=ytd,
))
return response
@router.get("/pensions/allowance", response_model=AllowanceSummary)
async def get_allowance_summary(
tax_year: int | None = Query(default=None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
year = tax_year or _current_tax_year()
return await pension_service.get_allowance_summary(db, current_user.id, year)
@router.get("/pensions/summary", response_model=YtdSummary)
async def get_pensions_ytd_summary(
tax_year: int | None = Query(default=None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
year = tax_year or _current_tax_year()
return await pension_service.get_all_pensions_ytd_summary(db, current_user.id, year)
# ---------------------------------------------------------------------------
# Pension metadata
# ---------------------------------------------------------------------------
@router.get("/pensions/{account_id}/metadata", response_model=PensionMetadataResponse)
async def get_pension_metadata(
account_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_pension_account(account_id, current_user.id, db)
meta = await pension_service.get_pension_metadata(db, account_id, current_user.id)
if not meta:
raise HTTPException(status_code=404, detail="No pension metadata for this account")
return PensionMetadataResponse(**pension_service.decode_metadata(meta))
@router.post("/pensions/{account_id}/metadata", response_model=PensionMetadataResponse, status_code=status.HTTP_201_CREATED)
async def create_pension_metadata(
account_id: uuid.UUID,
data: PensionMetadataCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_pension_account(account_id, current_user.id, db)
meta = await pension_service.upsert_pension_metadata(db, account_id, current_user.id, data)
await db.commit()
await db.refresh(meta)
return PensionMetadataResponse(**pension_service.decode_metadata(meta))
@router.put("/pensions/{account_id}/metadata", response_model=PensionMetadataResponse)
async def update_pension_metadata(
account_id: uuid.UUID,
data: PensionMetadataUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_pension_account(account_id, current_user.id, db)
meta = await pension_service.update_pension_metadata(db, account_id, current_user.id, data)
if not meta:
raise HTTPException(status_code=404, detail="No pension metadata for this account")
await db.commit()
await db.refresh(meta)
return PensionMetadataResponse(**pension_service.decode_metadata(meta))
# ---------------------------------------------------------------------------
# Contributions
# ---------------------------------------------------------------------------
@router.get("/pensions/{account_id}/contributions", response_model=list[PensionContributionResponse])
async def list_contributions(
account_id: uuid.UUID,
tax_year: int | None = Query(default=None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_pension_account(account_id, current_user.id, db)
meta = await pension_service.get_pension_metadata(db, account_id, current_user.id)
if not meta:
return []
rows = await pension_service.list_contributions(db, meta.id, current_user.id, tax_year)
return [PensionContributionResponse(**r) for r in rows]
@router.post("/pensions/{account_id}/contributions", response_model=PensionContributionResponse, status_code=status.HTTP_201_CREATED)
async def add_contribution(
account_id: uuid.UUID,
data: PensionContributionCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_pension_account(account_id, current_user.id, db)
meta = await pension_service.get_pension_metadata(db, account_id, current_user.id)
if not meta:
raise HTTPException(status_code=400, detail="Add pension details (type, provider) before recording contributions")
row = await pension_service.add_contribution(db, meta.id, current_user.id, data)
await db.commit()
return PensionContributionResponse(**row)
@router.put("/pensions/{account_id}/contributions/{contribution_id}", response_model=PensionContributionResponse)
async def update_contribution(
account_id: uuid.UUID,
contribution_id: uuid.UUID,
data: PensionContributionUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_pension_account(account_id, current_user.id, db)
row = await pension_service.update_contribution(db, contribution_id, current_user.id, data)
if not row:
raise HTTPException(status_code=404, detail="Contribution not found")
await db.commit()
return PensionContributionResponse(**row)
@router.delete("/pensions/{account_id}/contributions/{contribution_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_contribution(
account_id: uuid.UUID,
contribution_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_pension_account(account_id, current_user.id, db)
deleted = await pension_service.delete_contribution(db, contribution_id, current_user.id)
if not deleted:
raise HTTPException(status_code=404, detail="Contribution not found")
await db.commit()
# ---------------------------------------------------------------------------
# State Pension
# ---------------------------------------------------------------------------
@router.get("/pensions/state-pension", response_model=StatePensionResponse)
async def get_state_pension(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = await pension_service.get_state_pension(db, current_user.id)
if not record:
raise HTTPException(status_code=404, detail="No state pension record")
return StatePensionResponse(**pension_service._decode_sp(record))
@router.post("/pensions/state-pension", response_model=StatePensionResponse)
async def upsert_state_pension(
data: StatePensionCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await pension_service.upsert_state_pension(db, current_user.id, data)
await db.commit()
return StatePensionResponse(**result)
# ---------------------------------------------------------------------------
# Retirement projection
# ---------------------------------------------------------------------------
@router.get("/pensions/{account_id}/projection", response_model=RetirementProjection)
async def get_retirement_projection(
account_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_pension_account(account_id, current_user.id, db)
try:
return await pension_service.get_retirement_projection(db, current_user.id, account_id)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
# ---------------------------------------------------------------------------
# LISA summary
# ---------------------------------------------------------------------------
@router.get("/pensions/{account_id}/lisa-summary", response_model=LisaSummary)
async def get_lisa_summary(
account_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_pension_account(account_id, current_user.id, db)
try:
return await pension_service.get_lisa_summary(db, current_user.id, account_id)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))