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:
parent
b30e8e577b
commit
1a2c8efd01
30 changed files with 3537 additions and 8 deletions
|
|
@ -1,6 +1,6 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin, settings, subscriptions, tax
|
||||
from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin, settings, subscriptions, tax, pension
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
|
|
@ -16,3 +16,4 @@ router.include_router(admin.router)
|
|||
router.include_router(settings.router)
|
||||
router.include_router(subscriptions.router)
|
||||
router.include_router(tax.router)
|
||||
router.include_router(pension.router)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
306
backend/app/api/v1/pension.py
Normal file
306
backend/app/api/v1/pension.py
Normal 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))
|
||||
|
|
@ -12,10 +12,12 @@ from app.db.models.currency import Currency, ExchangeRate
|
|||
from app.db.models.net_worth_snapshot import NetWorthSnapshot
|
||||
from app.db.models.audit_log import AuditLog
|
||||
from app.db.models.tax import TaxRateConfig, TaxProfile, Payslip, ManualCGTDisposal
|
||||
from app.db.models.pension import PensionMetadata, PensionContribution, StatePensionRecord
|
||||
|
||||
__all__ = [
|
||||
"User", "Session", "Account", "Category", "Transaction", "Budget",
|
||||
"Asset", "AssetPrice", "InvestmentHolding", "InvestmentTransaction",
|
||||
"Currency", "ExchangeRate", "NetWorthSnapshot", "AuditLog",
|
||||
"TaxRateConfig", "TaxProfile", "Payslip", "ManualCGTDisposal",
|
||||
"PensionMetadata", "PensionContribution", "StatePensionRecord",
|
||||
]
|
||||
|
|
|
|||
59
backend/app/db/models/pension.py
Normal file
59
backend/app/db/models/pension.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, LargeBinary, Numeric, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PensionMetadata(Base):
|
||||
__tablename__ = "pension_metadata"
|
||||
|
||||
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)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
||||
pension_type: Mapped[str] = mapped_column(String(20), nullable=False) # workplace_dc|workplace_db|sipp|lisa
|
||||
provider_name_enc: Mapped[bytes | None] = mapped_column("provider_name", LargeBinary, nullable=True)
|
||||
scheme_name_enc: Mapped[bytes | None] = mapped_column("scheme_name", LargeBinary, nullable=True)
|
||||
member_reference_enc: Mapped[bytes | None] = mapped_column("member_reference", LargeBinary, nullable=True)
|
||||
dob: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
target_retirement_age: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
assumed_growth_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True) # e.g. 0.0500
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
account: Mapped["Account"] = relationship(lazy="noload") # type: ignore[name-defined]
|
||||
contributions: Mapped[list["PensionContribution"]] = relationship(back_populates="pension", lazy="noload", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class PensionContribution(Base):
|
||||
__tablename__ = "pension_contributions"
|
||||
|
||||
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)
|
||||
pension_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pension_metadata.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
contribution_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
tax_year: Mapped[int] = mapped_column(Integer, nullable=False, index=True) # year ending 5 Apr, e.g. 2026
|
||||
member_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
employer_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=0)
|
||||
relief_type: Mapped[str] = mapped_column(String(20), nullable=False) # relief_at_source|net_pay|salary_sacrifice|none
|
||||
gross_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
relief_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=0)
|
||||
notes_enc: Mapped[bytes | None] = mapped_column("notes", LargeBinary, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
pension: Mapped["PensionMetadata"] = relationship(back_populates="contributions", lazy="noload")
|
||||
|
||||
|
||||
class StatePensionRecord(Base):
|
||||
__tablename__ = "state_pension_records"
|
||||
|
||||
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, unique=True, index=True)
|
||||
qualifying_years: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
checked_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
195
backend/app/schemas/pension.py
Normal file
195
backend/app/schemas/pension.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import uuid
|
||||
from datetime import date as DateType, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
PensionType = Literal["workplace_dc", "workplace_db", "sipp", "lisa"]
|
||||
ReliefType = Literal["relief_at_source", "net_pay", "salary_sacrifice", "none"]
|
||||
|
||||
|
||||
class PensionMetadataCreate(BaseModel):
|
||||
pension_type: PensionType
|
||||
provider_name: str | None = None
|
||||
scheme_name: str | None = None
|
||||
member_reference: str | None = None
|
||||
dob: DateType | None = None
|
||||
target_retirement_age: int | None = Field(default=None, ge=55, le=90)
|
||||
assumed_growth_rate: Decimal | None = Field(default=None, ge=0, le=1)
|
||||
|
||||
|
||||
class PensionMetadataUpdate(BaseModel):
|
||||
pension_type: PensionType | None = None
|
||||
provider_name: str | None = None
|
||||
scheme_name: str | None = None
|
||||
member_reference: str | None = None
|
||||
dob: DateType | None = None
|
||||
target_retirement_age: int | None = Field(default=None, ge=55, le=90)
|
||||
assumed_growth_rate: Decimal | None = Field(default=None, ge=0, le=1)
|
||||
|
||||
|
||||
class PensionMetadataResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
account_id: uuid.UUID
|
||||
pension_type: PensionType
|
||||
provider_name: str | None
|
||||
scheme_name: str | None
|
||||
member_reference: str | None
|
||||
dob: DateType | None
|
||||
target_retirement_age: int | None
|
||||
assumed_growth_rate: Decimal | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PensionContributionCreate(BaseModel):
|
||||
contribution_date: DateType
|
||||
member_amount: Decimal = Field(..., ge=0)
|
||||
employer_amount: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
relief_type: ReliefType
|
||||
notes: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_amounts(self) -> "PensionContributionCreate":
|
||||
if self.member_amount == 0 and self.employer_amount == 0:
|
||||
raise ValueError("At least one of member_amount or employer_amount must be greater than 0")
|
||||
return self
|
||||
|
||||
|
||||
class PensionContributionUpdate(BaseModel):
|
||||
contribution_date: DateType | None = None
|
||||
member_amount: Decimal | None = Field(default=None, ge=0)
|
||||
employer_amount: Decimal | None = Field(default=None, ge=0)
|
||||
relief_type: ReliefType | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class PensionContributionResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
pension_id: uuid.UUID
|
||||
contribution_date: DateType
|
||||
tax_year: int
|
||||
member_amount: Decimal
|
||||
employer_amount: Decimal
|
||||
relief_type: ReliefType
|
||||
gross_amount: Decimal
|
||||
relief_amount: Decimal
|
||||
notes: str | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class YtdSummary(BaseModel):
|
||||
tax_year: int
|
||||
member_total: Decimal
|
||||
employer_total: Decimal
|
||||
gross_total: Decimal
|
||||
relief_total: Decimal
|
||||
contribution_count: int
|
||||
|
||||
|
||||
class PensionAccountResponse(BaseModel):
|
||||
"""Account row joined with pension metadata and YTD summary."""
|
||||
account_id: uuid.UUID
|
||||
account_name: str
|
||||
current_balance: Decimal
|
||||
currency: str
|
||||
color: str
|
||||
metadata: PensionMetadataResponse | None
|
||||
ytd: YtdSummary | None
|
||||
|
||||
|
||||
_FULL_SP_WEEKLY = Decimal("221.80")
|
||||
_SP_AGE = 67
|
||||
|
||||
|
||||
class StatePensionCreate(BaseModel):
|
||||
qualifying_years: int = Field(..., ge=0, le=50)
|
||||
checked_date: DateType | None = None
|
||||
|
||||
|
||||
class StatePensionResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
qualifying_years: int
|
||||
checked_date: DateType | None
|
||||
weekly_amount: Decimal
|
||||
annual_amount: Decimal
|
||||
is_full_pension: bool
|
||||
years_to_full: int
|
||||
state_pension_age: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProjectionScenario(BaseModel):
|
||||
label: str
|
||||
growth_rate: Decimal
|
||||
projected_pot: Decimal
|
||||
annual_drawdown_4pct: Decimal
|
||||
annual_drawdown_3pct: Decimal
|
||||
|
||||
|
||||
class ChartDataPoint(BaseModel):
|
||||
year: int
|
||||
pot_2pct: Decimal
|
||||
pot_5pct: Decimal
|
||||
pot_8pct: Decimal
|
||||
|
||||
|
||||
class RetirementProjection(BaseModel):
|
||||
account_id: uuid.UUID
|
||||
account_name: str
|
||||
current_balance: Decimal
|
||||
years_to_retirement: int
|
||||
target_retirement_age: int
|
||||
scenarios: list[ProjectionScenario]
|
||||
state_pension_annual: Decimal | None
|
||||
state_pension_age: int
|
||||
chart_data: list[ChartDataPoint]
|
||||
|
||||
|
||||
class LisaTaxYearBreakdown(BaseModel):
|
||||
tax_year: int
|
||||
contributions: Decimal
|
||||
bonus_expected: Decimal
|
||||
limit_remaining: Decimal
|
||||
limit_used_pct: Decimal
|
||||
|
||||
|
||||
class LisaSummary(BaseModel):
|
||||
account_id: uuid.UUID
|
||||
account_name: str
|
||||
tax_year_breakdown: list[LisaTaxYearBreakdown]
|
||||
current_year_contributions: Decimal
|
||||
current_year_bonus_expected: Decimal
|
||||
current_year_limit_remaining: Decimal
|
||||
total_contributions: Decimal
|
||||
total_bonus_expected: Decimal
|
||||
account_opened_date: datetime
|
||||
withdrawal_penalty_amount: Decimal
|
||||
withdrawal_penalty_pct: Decimal
|
||||
penalty_warning: bool
|
||||
|
||||
|
||||
class CarryForwardYear(BaseModel):
|
||||
tax_year: int
|
||||
standard_allowance: Decimal
|
||||
contributions: Decimal
|
||||
unused: Decimal
|
||||
|
||||
|
||||
class AllowanceSummary(BaseModel):
|
||||
tax_year: int
|
||||
standard_allowance: Decimal
|
||||
contributions_total: Decimal
|
||||
remaining: Decimal
|
||||
carry_forward: list[CarryForwardYear]
|
||||
carry_forward_total: Decimal
|
||||
total_available: Decimal
|
||||
relief_ras_total: Decimal
|
||||
relief_higher_rate_claimable: Decimal
|
||||
relief_additional_rate_claimable: Decimal
|
||||
|
|
@ -203,6 +203,17 @@ class IncomeSummary(BaseModel):
|
|||
payslips: list[dict[str, Any]]
|
||||
|
||||
|
||||
class PensionTaxSummary(BaseModel):
|
||||
net_pay_total: str
|
||||
salary_sacrifice_total: str
|
||||
ras_gross_total: str
|
||||
higher_rate_claimable: str
|
||||
additional_rate_claimable: str
|
||||
annual_allowance_used: str
|
||||
annual_allowance_remaining: str
|
||||
standard_allowance: str
|
||||
|
||||
|
||||
class TaxReportResponse(BaseModel):
|
||||
tax_year: int
|
||||
tax_year_display: str
|
||||
|
|
@ -212,4 +223,5 @@ class TaxReportResponse(BaseModel):
|
|||
ni: NISummary
|
||||
cgt: CGTSummary
|
||||
dividends: DividendSummary
|
||||
pensions: PensionTaxSummary | None
|
||||
summary: TaxReportSummary
|
||||
|
|
|
|||
664
backend/app/services/pension_service.py
Normal file
664
backend/app/services/pension_service.py
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.db.models.account import Account
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.security import decrypt_field, encrypt_field
|
||||
from app.db.models.pension import PensionContribution, PensionMetadata, StatePensionRecord
|
||||
from app.schemas.pension import (
|
||||
AllowanceSummary,
|
||||
CarryForwardYear,
|
||||
ChartDataPoint,
|
||||
LisaSummary,
|
||||
LisaTaxYearBreakdown,
|
||||
PensionContributionCreate,
|
||||
PensionContributionUpdate,
|
||||
PensionMetadataCreate,
|
||||
PensionMetadataUpdate,
|
||||
ProjectionScenario,
|
||||
RetirementProjection,
|
||||
StatePensionCreate,
|
||||
YtdSummary,
|
||||
_FULL_SP_WEEKLY,
|
||||
_SP_AGE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
def _uk_tax_year(d: date) -> int:
|
||||
"""Return the tax year ending on 5 Apr that contains date d.
|
||||
E.g. 2026-01-01 → 2026; 2025-04-05 → 2025; 2025-04-06 → 2026."""
|
||||
return d.year + 1 if (d.month > 4 or (d.month == 4 and d.day >= 6)) else d.year
|
||||
|
||||
|
||||
def _compute_gross_and_relief(
|
||||
member_amount: Decimal,
|
||||
relief_type: str,
|
||||
) -> tuple[Decimal, Decimal]:
|
||||
"""Return (gross_amount, relief_amount) given a net member contribution and relief type."""
|
||||
if relief_type == "relief_at_source":
|
||||
gross = member_amount / Decimal("0.8")
|
||||
relief = gross - member_amount
|
||||
elif relief_type in ("net_pay", "salary_sacrifice"):
|
||||
gross = member_amount
|
||||
relief = Decimal("0")
|
||||
else: # none
|
||||
gross = member_amount
|
||||
relief = Decimal("0")
|
||||
return gross.quantize(Decimal("0.01")), relief.quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def _enc(v: str | None) -> bytes | None:
|
||||
return encrypt_field(v) if v else None
|
||||
|
||||
|
||||
def _dec(v: bytes | None) -> str | None:
|
||||
return decrypt_field(v) if v else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pension metadata (one per pension account)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_pension_metadata(
|
||||
db: "AsyncSession",
|
||||
account_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> PensionMetadata | None:
|
||||
result = await db.execute(
|
||||
select(PensionMetadata).where(
|
||||
PensionMetadata.account_id == account_id,
|
||||
PensionMetadata.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_pension_metadata(
|
||||
db: "AsyncSession",
|
||||
account_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
data: PensionMetadataCreate,
|
||||
) -> PensionMetadata:
|
||||
existing = await get_pension_metadata(db, account_id, user_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
if existing:
|
||||
existing.pension_type = data.pension_type
|
||||
existing.provider_name_enc = _enc(data.provider_name)
|
||||
existing.scheme_name_enc = _enc(data.scheme_name)
|
||||
existing.member_reference_enc = _enc(data.member_reference)
|
||||
existing.dob = data.dob
|
||||
existing.target_retirement_age = data.target_retirement_age
|
||||
existing.assumed_growth_rate = data.assumed_growth_rate
|
||||
existing.updated_at = now
|
||||
return existing
|
||||
meta = PensionMetadata(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
pension_type=data.pension_type,
|
||||
provider_name_enc=_enc(data.provider_name),
|
||||
scheme_name_enc=_enc(data.scheme_name),
|
||||
member_reference_enc=_enc(data.member_reference),
|
||||
dob=data.dob,
|
||||
target_retirement_age=data.target_retirement_age,
|
||||
assumed_growth_rate=data.assumed_growth_rate,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(meta)
|
||||
return meta
|
||||
|
||||
|
||||
async def update_pension_metadata(
|
||||
db: "AsyncSession",
|
||||
account_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
data: PensionMetadataUpdate,
|
||||
) -> PensionMetadata | None:
|
||||
meta = await get_pension_metadata(db, account_id, user_id)
|
||||
if not meta:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
if data.pension_type is not None:
|
||||
meta.pension_type = data.pension_type
|
||||
if data.provider_name is not None:
|
||||
meta.provider_name_enc = _enc(data.provider_name)
|
||||
if data.scheme_name is not None:
|
||||
meta.scheme_name_enc = _enc(data.scheme_name)
|
||||
if data.member_reference is not None:
|
||||
meta.member_reference_enc = _enc(data.member_reference)
|
||||
if data.dob is not None:
|
||||
meta.dob = data.dob
|
||||
if data.target_retirement_age is not None:
|
||||
meta.target_retirement_age = data.target_retirement_age
|
||||
if data.assumed_growth_rate is not None:
|
||||
meta.assumed_growth_rate = data.assumed_growth_rate
|
||||
meta.updated_at = now
|
||||
return meta
|
||||
|
||||
|
||||
def decode_metadata(meta: PensionMetadata) -> dict:
|
||||
return {
|
||||
"id": meta.id,
|
||||
"account_id": meta.account_id,
|
||||
"pension_type": meta.pension_type,
|
||||
"provider_name": _dec(meta.provider_name_enc),
|
||||
"scheme_name": _dec(meta.scheme_name_enc),
|
||||
"member_reference": _dec(meta.member_reference_enc),
|
||||
"dob": meta.dob,
|
||||
"target_retirement_age": meta.target_retirement_age,
|
||||
"assumed_growth_rate": meta.assumed_growth_rate,
|
||||
"created_at": meta.created_at,
|
||||
"updated_at": meta.updated_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contributions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def list_contributions(
|
||||
db: "AsyncSession",
|
||||
pension_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
tax_year: int | None = None,
|
||||
) -> list[dict]:
|
||||
q = select(PensionContribution).where(
|
||||
PensionContribution.pension_id == pension_id,
|
||||
PensionContribution.user_id == user_id,
|
||||
)
|
||||
if tax_year is not None:
|
||||
q = q.where(PensionContribution.tax_year == tax_year)
|
||||
q = q.order_by(PensionContribution.contribution_date.desc())
|
||||
result = await db.execute(q)
|
||||
rows = result.scalars().all()
|
||||
return [_decode_contribution(r) for r in rows]
|
||||
|
||||
|
||||
async def add_contribution(
|
||||
db: "AsyncSession",
|
||||
pension_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
data: PensionContributionCreate,
|
||||
) -> dict:
|
||||
gross, relief = _compute_gross_and_relief(data.member_amount, data.relief_type)
|
||||
tax_year = _uk_tax_year(data.contribution_date)
|
||||
contrib = PensionContribution(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
pension_id=pension_id,
|
||||
contribution_date=data.contribution_date,
|
||||
tax_year=tax_year,
|
||||
member_amount=data.member_amount,
|
||||
employer_amount=data.employer_amount,
|
||||
relief_type=data.relief_type,
|
||||
gross_amount=gross,
|
||||
relief_amount=relief,
|
||||
notes_enc=_enc(data.notes),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(contrib)
|
||||
return _decode_contribution(contrib)
|
||||
|
||||
|
||||
async def update_contribution(
|
||||
db: "AsyncSession",
|
||||
contribution_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
data: PensionContributionUpdate,
|
||||
) -> dict | None:
|
||||
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:
|
||||
return None
|
||||
|
||||
if data.contribution_date is not None:
|
||||
contrib.contribution_date = data.contribution_date
|
||||
contrib.tax_year = _uk_tax_year(data.contribution_date)
|
||||
if data.member_amount is not None:
|
||||
contrib.member_amount = data.member_amount
|
||||
if data.employer_amount is not None:
|
||||
contrib.employer_amount = data.employer_amount
|
||||
if data.relief_type is not None:
|
||||
contrib.relief_type = data.relief_type
|
||||
if data.notes is not None:
|
||||
contrib.notes_enc = _enc(data.notes)
|
||||
|
||||
gross, relief = _compute_gross_and_relief(contrib.member_amount, contrib.relief_type)
|
||||
contrib.gross_amount = gross
|
||||
contrib.relief_amount = relief
|
||||
return _decode_contribution(contrib)
|
||||
|
||||
|
||||
async def delete_contribution(
|
||||
db: "AsyncSession",
|
||||
contribution_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> bool:
|
||||
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:
|
||||
return False
|
||||
await db.delete(contrib)
|
||||
return True
|
||||
|
||||
|
||||
def _decode_contribution(c: PensionContribution) -> dict:
|
||||
return {
|
||||
"id": c.id,
|
||||
"pension_id": c.pension_id,
|
||||
"contribution_date": c.contribution_date,
|
||||
"tax_year": c.tax_year,
|
||||
"member_amount": c.member_amount,
|
||||
"employer_amount": c.employer_amount,
|
||||
"relief_type": c.relief_type,
|
||||
"gross_amount": c.gross_amount,
|
||||
"relief_amount": c.relief_amount,
|
||||
"notes": _dec(c.notes_enc),
|
||||
"created_at": c.created_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YTD / summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_ytd_summary(
|
||||
db: "AsyncSession",
|
||||
pension_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
tax_year: int,
|
||||
) -> YtdSummary:
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(PensionContribution.id).label("count"),
|
||||
func.coalesce(func.sum(PensionContribution.member_amount), 0).label("member_total"),
|
||||
func.coalesce(func.sum(PensionContribution.employer_amount), 0).label("employer_total"),
|
||||
func.coalesce(func.sum(PensionContribution.gross_amount), 0).label("gross_total"),
|
||||
func.coalesce(func.sum(PensionContribution.relief_amount), 0).label("relief_total"),
|
||||
).where(
|
||||
PensionContribution.pension_id == pension_id,
|
||||
PensionContribution.user_id == user_id,
|
||||
PensionContribution.tax_year == tax_year,
|
||||
)
|
||||
)
|
||||
row = result.one()
|
||||
return YtdSummary(
|
||||
tax_year=tax_year,
|
||||
member_total=Decimal(str(row.member_total)),
|
||||
employer_total=Decimal(str(row.employer_total)),
|
||||
gross_total=Decimal(str(row.gross_total)),
|
||||
relief_total=Decimal(str(row.relief_total)),
|
||||
contribution_count=row.count,
|
||||
)
|
||||
|
||||
|
||||
async def get_all_pensions_ytd_summary(
|
||||
db: "AsyncSession",
|
||||
user_id: uuid.UUID,
|
||||
tax_year: int,
|
||||
) -> YtdSummary:
|
||||
"""Aggregate YTD totals across all pension accounts for the summary header."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(PensionContribution.id).label("count"),
|
||||
func.coalesce(func.sum(PensionContribution.member_amount), 0).label("member_total"),
|
||||
func.coalesce(func.sum(PensionContribution.employer_amount), 0).label("employer_total"),
|
||||
func.coalesce(func.sum(PensionContribution.gross_amount), 0).label("gross_total"),
|
||||
func.coalesce(func.sum(PensionContribution.relief_amount), 0).label("relief_total"),
|
||||
).where(
|
||||
PensionContribution.user_id == user_id,
|
||||
PensionContribution.tax_year == tax_year,
|
||||
)
|
||||
)
|
||||
row = result.one()
|
||||
return YtdSummary(
|
||||
tax_year=tax_year,
|
||||
member_total=Decimal(str(row.member_total)),
|
||||
employer_total=Decimal(str(row.employer_total)),
|
||||
gross_total=Decimal(str(row.gross_total)),
|
||||
relief_total=Decimal(str(row.relief_total)),
|
||||
contribution_count=row.count,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Annual allowance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STANDARD_ALLOWANCE = Decimal("60000")
|
||||
|
||||
|
||||
async def get_allowance_summary(
|
||||
db: "AsyncSession",
|
||||
user_id: uuid.UUID,
|
||||
tax_year: int,
|
||||
) -> AllowanceSummary:
|
||||
"""
|
||||
Returns annual allowance usage for tax_year plus carry-forward from
|
||||
the prior 3 tax years (oldest-first, as HMRC requires).
|
||||
"""
|
||||
years_to_fetch = [tax_year - 3, tax_year - 2, tax_year - 1, tax_year]
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
PensionContribution.tax_year,
|
||||
PensionContribution.relief_type,
|
||||
func.coalesce(func.sum(PensionContribution.gross_amount), 0).label("gross_total"),
|
||||
func.coalesce(func.sum(PensionContribution.relief_amount), 0).label("relief_total"),
|
||||
).where(
|
||||
PensionContribution.user_id == user_id,
|
||||
PensionContribution.tax_year.in_(years_to_fetch),
|
||||
).group_by(
|
||||
PensionContribution.tax_year,
|
||||
PensionContribution.relief_type,
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
year_gross: dict[int, Decimal] = {y: Decimal("0") for y in years_to_fetch}
|
||||
year_ras_relief: dict[int, Decimal] = {y: Decimal("0") for y in years_to_fetch}
|
||||
|
||||
for row in rows:
|
||||
year_gross[row.tax_year] = year_gross.get(row.tax_year, Decimal("0")) + Decimal(str(row.gross_total))
|
||||
if row.relief_type == "relief_at_source":
|
||||
year_ras_relief[row.tax_year] = year_ras_relief.get(row.tax_year, Decimal("0")) + Decimal(str(row.relief_total))
|
||||
|
||||
carry_forward: list[CarryForwardYear] = []
|
||||
carry_forward_total = Decimal("0")
|
||||
for yr in [tax_year - 3, tax_year - 2, tax_year - 1]:
|
||||
contribs = year_gross[yr]
|
||||
unused = max(Decimal("0"), _STANDARD_ALLOWANCE - contribs)
|
||||
carry_forward.append(CarryForwardYear(
|
||||
tax_year=yr,
|
||||
standard_allowance=_STANDARD_ALLOWANCE,
|
||||
contributions=contribs,
|
||||
unused=unused,
|
||||
))
|
||||
carry_forward_total += unused
|
||||
|
||||
contributions_total = year_gross[tax_year]
|
||||
remaining = max(Decimal("0"), _STANDARD_ALLOWANCE - contributions_total)
|
||||
total_available = _STANDARD_ALLOWANCE + carry_forward_total
|
||||
|
||||
ras_relief = year_ras_relief[tax_year]
|
||||
ras_gross_current = sum(
|
||||
(Decimal(str(r.gross_total)) for r in rows if r.tax_year == tax_year and r.relief_type == "relief_at_source"),
|
||||
Decimal("0"),
|
||||
)
|
||||
higher_rate_claimable = (ras_gross_current * Decimal("0.20")).quantize(Decimal("0.01"))
|
||||
additional_rate_claimable = (ras_gross_current * Decimal("0.05")).quantize(Decimal("0.01"))
|
||||
|
||||
return AllowanceSummary(
|
||||
tax_year=tax_year,
|
||||
standard_allowance=_STANDARD_ALLOWANCE,
|
||||
contributions_total=contributions_total,
|
||||
remaining=remaining,
|
||||
carry_forward=carry_forward,
|
||||
carry_forward_total=carry_forward_total,
|
||||
total_available=total_available,
|
||||
relief_ras_total=ras_relief,
|
||||
relief_higher_rate_claimable=higher_rate_claimable,
|
||||
relief_additional_rate_claimable=additional_rate_claimable,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State Pension
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _sp_amounts(qualifying_years: int) -> tuple[Decimal, Decimal]:
|
||||
weekly = min(_FULL_SP_WEEKLY, (_FULL_SP_WEEKLY * qualifying_years / 35).quantize(Decimal("0.01")))
|
||||
return weekly, (weekly * 52).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
async def get_state_pension(
|
||||
db: "AsyncSession",
|
||||
user_id: uuid.UUID,
|
||||
) -> StatePensionRecord | None:
|
||||
result = await db.execute(
|
||||
select(StatePensionRecord).where(StatePensionRecord.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_state_pension(
|
||||
db: "AsyncSession",
|
||||
user_id: uuid.UUID,
|
||||
data: StatePensionCreate,
|
||||
) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
existing = await get_state_pension(db, user_id)
|
||||
if existing:
|
||||
existing.qualifying_years = data.qualifying_years
|
||||
existing.checked_date = data.checked_date
|
||||
existing.updated_at = now
|
||||
record = existing
|
||||
else:
|
||||
record = StatePensionRecord(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
qualifying_years=data.qualifying_years,
|
||||
checked_date=data.checked_date,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(record)
|
||||
return _decode_sp(record)
|
||||
|
||||
|
||||
def _decode_sp(record: StatePensionRecord) -> dict:
|
||||
weekly, annual = _sp_amounts(record.qualifying_years)
|
||||
years_to_full = max(0, 35 - record.qualifying_years)
|
||||
return {
|
||||
"id": record.id,
|
||||
"qualifying_years": record.qualifying_years,
|
||||
"checked_date": record.checked_date,
|
||||
"weekly_amount": weekly,
|
||||
"annual_amount": annual,
|
||||
"is_full_pension": record.qualifying_years >= 35,
|
||||
"years_to_full": years_to_full,
|
||||
"state_pension_age": _SP_AGE,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retirement projection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SCENARIOS = [
|
||||
("Conservative 2%", Decimal("0.02")),
|
||||
("Moderate 5%", Decimal("0.05")),
|
||||
("Growth 8%", Decimal("0.08")),
|
||||
]
|
||||
|
||||
|
||||
async def get_retirement_projection(
|
||||
db: "AsyncSession",
|
||||
user_id: uuid.UUID,
|
||||
account_id: uuid.UUID,
|
||||
) -> RetirementProjection:
|
||||
acc_result = await db.execute(
|
||||
select(Account).where(Account.id == account_id, Account.user_id == user_id, Account.deleted_at.is_(None))
|
||||
)
|
||||
account = acc_result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise ValueError("Account not found")
|
||||
|
||||
meta = await get_pension_metadata(db, account_id, user_id)
|
||||
if not meta or not meta.dob or not meta.target_retirement_age:
|
||||
raise ValueError("Set date of birth and target retirement age in pension details first")
|
||||
|
||||
today = date.today()
|
||||
age_today = (today - meta.dob).days / 365.25
|
||||
years_to_retirement = max(0, int(meta.target_retirement_age - age_today))
|
||||
|
||||
current_balance = Decimal(str(account.current_balance))
|
||||
|
||||
# Build year-by-year chart data
|
||||
chart_data: list[ChartDataPoint] = []
|
||||
pots = {r: current_balance for _, r in _SCENARIOS}
|
||||
current_year = today.year
|
||||
for yr in range(years_to_retirement + 1):
|
||||
chart_data.append(ChartDataPoint(
|
||||
year=current_year + yr,
|
||||
pot_2pct=pots[Decimal("0.02")].quantize(Decimal("1")),
|
||||
pot_5pct=pots[Decimal("0.05")].quantize(Decimal("1")),
|
||||
pot_8pct=pots[Decimal("0.08")].quantize(Decimal("1")),
|
||||
))
|
||||
for _, rate in _SCENARIOS:
|
||||
pots[rate] = pots[rate] * (1 + rate)
|
||||
|
||||
# Final pot values at retirement (last chart point)
|
||||
final = chart_data[-1] if chart_data else None
|
||||
scenario_pots = {
|
||||
Decimal("0.02"): Decimal(str(final.pot_2pct)) if final else current_balance,
|
||||
Decimal("0.05"): Decimal(str(final.pot_5pct)) if final else current_balance,
|
||||
Decimal("0.08"): Decimal(str(final.pot_8pct)) if final else current_balance,
|
||||
}
|
||||
|
||||
scenarios = [
|
||||
ProjectionScenario(
|
||||
label=label,
|
||||
growth_rate=rate,
|
||||
projected_pot=scenario_pots[rate],
|
||||
annual_drawdown_4pct=(scenario_pots[rate] * Decimal("0.04")).quantize(Decimal("1")),
|
||||
annual_drawdown_3pct=(scenario_pots[rate] * Decimal("0.03")).quantize(Decimal("1")),
|
||||
)
|
||||
for label, rate in _SCENARIOS
|
||||
]
|
||||
|
||||
# State pension
|
||||
sp_record = await get_state_pension(db, user_id)
|
||||
sp_annual = None
|
||||
if sp_record:
|
||||
_, sp_annual = _sp_amounts(sp_record.qualifying_years)
|
||||
|
||||
from app.core.security import decrypt_field
|
||||
acc_name = decrypt_field(account.name_enc) if account.name_enc else ""
|
||||
|
||||
return RetirementProjection(
|
||||
account_id=account_id,
|
||||
account_name=acc_name,
|
||||
current_balance=current_balance,
|
||||
years_to_retirement=years_to_retirement,
|
||||
target_retirement_age=meta.target_retirement_age,
|
||||
scenarios=scenarios,
|
||||
state_pension_annual=sp_annual,
|
||||
state_pension_age=_SP_AGE,
|
||||
chart_data=chart_data,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LISA summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LISA_ANNUAL_LIMIT = Decimal("4000")
|
||||
_LISA_BONUS_RATE = Decimal("0.25")
|
||||
_LISA_WITHDRAWAL_PENALTY = Decimal("0.25")
|
||||
|
||||
|
||||
async def get_lisa_summary(
|
||||
db: "AsyncSession",
|
||||
user_id: uuid.UUID,
|
||||
account_id: uuid.UUID,
|
||||
) -> LisaSummary:
|
||||
# Verify account is LISA type
|
||||
meta_result = await db.execute(
|
||||
select(PensionMetadata).where(
|
||||
PensionMetadata.account_id == account_id,
|
||||
PensionMetadata.user_id == user_id,
|
||||
)
|
||||
)
|
||||
meta = meta_result.scalar_one_or_none()
|
||||
if meta is None or meta.pension_type != "lisa":
|
||||
raise ValueError("Account is not a LISA")
|
||||
|
||||
# Load account for balance and created_at
|
||||
acc_result = await db.execute(
|
||||
select(Account).where(Account.id == account_id, Account.user_id == user_id)
|
||||
)
|
||||
account = acc_result.scalar_one_or_none()
|
||||
if account is None:
|
||||
raise ValueError("Account not found")
|
||||
|
||||
current_balance = Decimal(str(account.current_balance or 0))
|
||||
|
||||
# Load all contributions
|
||||
contrib_result = await db.execute(
|
||||
select(PensionContribution).where(
|
||||
PensionContribution.pension_id == meta.id,
|
||||
PensionContribution.user_id == user_id,
|
||||
)
|
||||
)
|
||||
contribs = contrib_result.scalars().all()
|
||||
|
||||
# Group by tax year
|
||||
by_year: dict[int, Decimal] = {}
|
||||
for c in contribs:
|
||||
by_year[c.tax_year] = by_year.get(c.tax_year, Decimal("0")) + Decimal(str(c.member_amount))
|
||||
|
||||
current_year = _uk_tax_year(date.today())
|
||||
|
||||
breakdown: list[LisaTaxYearBreakdown] = []
|
||||
for ty in sorted(by_year):
|
||||
contributions = by_year[ty].quantize(Decimal("0.01"))
|
||||
capped = min(contributions, _LISA_ANNUAL_LIMIT)
|
||||
bonus = (capped * _LISA_BONUS_RATE).quantize(Decimal("0.01"))
|
||||
limit_remaining = max(Decimal("0"), _LISA_ANNUAL_LIMIT - contributions).quantize(Decimal("0.01"))
|
||||
limit_used_pct = min(Decimal("100"), (contributions / _LISA_ANNUAL_LIMIT * 100)).quantize(Decimal("0.01"))
|
||||
breakdown.append(LisaTaxYearBreakdown(
|
||||
tax_year=ty,
|
||||
contributions=contributions,
|
||||
bonus_expected=bonus,
|
||||
limit_remaining=limit_remaining,
|
||||
limit_used_pct=limit_used_pct,
|
||||
))
|
||||
|
||||
current_contributions = by_year.get(current_year, Decimal("0")).quantize(Decimal("0.01"))
|
||||
current_capped = min(current_contributions, _LISA_ANNUAL_LIMIT)
|
||||
current_bonus = (current_capped * _LISA_BONUS_RATE).quantize(Decimal("0.01"))
|
||||
current_limit_remaining = max(Decimal("0"), _LISA_ANNUAL_LIMIT - current_contributions).quantize(Decimal("0.01"))
|
||||
|
||||
total_contributions = sum(by_year.values(), Decimal("0")).quantize(Decimal("0.01"))
|
||||
total_capped = sum(min(v, _LISA_ANNUAL_LIMIT) for v in by_year.values())
|
||||
total_bonus = (total_capped * _LISA_BONUS_RATE).quantize(Decimal("0.01"))
|
||||
|
||||
# Withdrawal penalty: 25% of current balance (which includes any bonus already paid)
|
||||
penalty_amount = (current_balance * _LISA_WITHDRAWAL_PENALTY).quantize(Decimal("0.01"))
|
||||
penalty_pct = _LISA_WITHDRAWAL_PENALTY * 100
|
||||
|
||||
return LisaSummary(
|
||||
account_id=account_id,
|
||||
account_name=decrypt_field(account.name_enc) if account.name_enc else "",
|
||||
tax_year_breakdown=breakdown,
|
||||
current_year_contributions=current_contributions,
|
||||
current_year_bonus_expected=current_bonus,
|
||||
current_year_limit_remaining=current_limit_remaining,
|
||||
total_contributions=total_contributions,
|
||||
total_bonus_expected=total_bonus,
|
||||
account_opened_date=account.created_at,
|
||||
withdrawal_penalty_amount=penalty_amount,
|
||||
withdrawal_penalty_pct=penalty_pct,
|
||||
penalty_warning=True,
|
||||
)
|
||||
|
|
@ -11,7 +11,7 @@ from datetime import date, datetime, timezone
|
|||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import decrypt_field, encrypt_field
|
||||
|
|
@ -595,6 +595,7 @@ async def build_tax_report(
|
|||
from app.db.models.investment_transaction import InvestmentTransaction
|
||||
from app.db.models.investment_holding import InvestmentHolding
|
||||
from app.db.models.asset import Asset
|
||||
from app.db.models.pension import PensionContribution, PensionMetadata
|
||||
|
||||
rates = await load_rates(db, user_id, tax_year)
|
||||
start_date, end_date = tax_year_date_range(tax_year)
|
||||
|
|
@ -676,6 +677,43 @@ async def build_tax_report(
|
|||
"amount": str(amount),
|
||||
})
|
||||
|
||||
# ---- Pension contributions ----
|
||||
_PENSION_ALLOWANCE = Decimal("60000")
|
||||
pension_result = await db.execute(
|
||||
select(
|
||||
PensionContribution.relief_type,
|
||||
func.coalesce(func.sum(PensionContribution.member_amount), 0).label("member_total"),
|
||||
func.coalesce(func.sum(PensionContribution.gross_amount), 0).label("gross_total"),
|
||||
)
|
||||
.join(PensionMetadata, PensionContribution.pension_id == PensionMetadata.id)
|
||||
.where(
|
||||
PensionContribution.user_id == user_id,
|
||||
PensionContribution.tax_year == tax_year,
|
||||
PensionMetadata.pension_type != "lisa",
|
||||
)
|
||||
.group_by(PensionContribution.relief_type)
|
||||
)
|
||||
pension_rows = pension_result.all()
|
||||
|
||||
net_pay_total = Decimal("0")
|
||||
salary_sacrifice_total = Decimal("0")
|
||||
ras_gross_total = Decimal("0")
|
||||
annual_allowance_used = Decimal("0")
|
||||
for p_row in pension_rows:
|
||||
gross = Decimal(str(p_row.gross_total))
|
||||
annual_allowance_used += gross
|
||||
if p_row.relief_type == "net_pay":
|
||||
net_pay_total += Decimal(str(p_row.member_total))
|
||||
elif p_row.relief_type == "salary_sacrifice":
|
||||
salary_sacrifice_total += Decimal(str(p_row.member_total))
|
||||
elif p_row.relief_type == "relief_at_source":
|
||||
ras_gross_total += gross
|
||||
|
||||
annual_allowance_remaining = max(Decimal("0"), _PENSION_ALLOWANCE - annual_allowance_used)
|
||||
higher_rate_claimable = (ras_gross_total * Decimal("0.20")).quantize(Decimal("0.01"))
|
||||
additional_rate_claimable = (ras_gross_total * Decimal("0.05")).quantize(Decimal("0.01"))
|
||||
has_pension_data = annual_allowance_used > 0
|
||||
|
||||
# ---- Calculations ----
|
||||
income_tax_result = calculate_income_tax(gross_income, tax_code, rates)
|
||||
ni_result = calculate_ni(gross_income, rates)
|
||||
|
|
@ -735,6 +773,16 @@ async def build_tax_report(
|
|||
for k, v in dividend_result.items()},
|
||||
"dividend_transactions": dividend_rows,
|
||||
},
|
||||
"pensions": {
|
||||
"net_pay_total": str(net_pay_total),
|
||||
"salary_sacrifice_total": str(salary_sacrifice_total),
|
||||
"ras_gross_total": str(ras_gross_total),
|
||||
"higher_rate_claimable": str(higher_rate_claimable),
|
||||
"additional_rate_claimable": str(additional_rate_claimable),
|
||||
"annual_allowance_used": str(annual_allowance_used),
|
||||
"annual_allowance_remaining": str(annual_allowance_remaining),
|
||||
"standard_allowance": str(_PENSION_ALLOWANCE),
|
||||
} if has_pension_data else None,
|
||||
"summary": {
|
||||
"total_liability": str(total_liability),
|
||||
"total_withheld": str(total_withheld),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue