diff --git a/backend/Dockerfile b/backend/Dockerfile index 8a5d243..644b93c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,6 +16,14 @@ COPY pyproject.toml ./ FROM base AS deps RUN uv pip install --system --no-cache -e . +FROM deps AS test +COPY app/ ./app/ +COPY alembic/ ./alembic/ +COPY alembic.ini ./ +COPY tests/ ./tests/ +RUN uv pip install --system --no-cache "pytest>=8" "pytest-asyncio>=0.20" "fakeredis[aioredis]" "httpx>=0.27" +CMD ["python", "-m", "pytest", "tests/", "-v", "--tb=short"] + FROM deps AS production COPY app/ ./app/ COPY alembic/ ./alembic/ diff --git a/backend/alembic/versions/0007_add_pension_tables.py b/backend/alembic/versions/0007_add_pension_tables.py new file mode 100644 index 0000000..cbafdbe --- /dev/null +++ b/backend/alembic/versions/0007_add_pension_tables.py @@ -0,0 +1,84 @@ +"""add pension tables + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-04-27 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0007" +down_revision = "0006" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ------------------------------------------------------------------ + # pension_metadata — one row per pension account + # ------------------------------------------------------------------ + op.create_table( + "pension_metadata", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("account_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False), + sa.Column("pension_type", sa.String(20), nullable=False), + sa.Column("provider_name", sa.LargeBinary, nullable=True), + sa.Column("scheme_name", sa.LargeBinary, nullable=True), + sa.Column("member_reference", sa.LargeBinary, nullable=True), + sa.Column("dob", sa.Date, nullable=True), + sa.Column("target_retirement_age", sa.Integer, nullable=True), + sa.Column("assumed_growth_rate", sa.Numeric(5, 4), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_unique_constraint("uq_pension_metadata_account_id", "pension_metadata", ["account_id"]) + op.create_index("ix_pension_metadata_user_id", "pension_metadata", ["user_id"]) + op.create_index("ix_pension_metadata_account_id", "pension_metadata", ["account_id"]) + + # ------------------------------------------------------------------ + # pension_contributions — contribution history per pension + # ------------------------------------------------------------------ + op.create_table( + "pension_contributions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("pension_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("pension_metadata.id", ondelete="CASCADE"), nullable=False), + sa.Column("contribution_date", sa.Date, nullable=False), + sa.Column("tax_year", sa.Integer, nullable=False), + sa.Column("member_amount", sa.Numeric(14, 2), nullable=False), + sa.Column("employer_amount", sa.Numeric(14, 2), nullable=False, server_default="0"), + sa.Column("relief_type", sa.String(20), nullable=False), + sa.Column("gross_amount", sa.Numeric(14, 2), nullable=False), + sa.Column("relief_amount", sa.Numeric(14, 2), nullable=False, server_default="0"), + sa.Column("notes", sa.LargeBinary, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_pension_contributions_user_id", "pension_contributions", ["user_id"]) + op.create_index("ix_pension_contributions_pension_id", "pension_contributions", ["pension_id"]) + op.create_index("ix_pension_contributions_date", "pension_contributions", ["contribution_date"]) + op.create_index("ix_pension_contributions_tax_year", "pension_contributions", ["tax_year"]) + + # ------------------------------------------------------------------ + # RLS + # ------------------------------------------------------------------ + for table in ["pension_metadata", "pension_contributions"]: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f""" + CREATE POLICY {table}_user_isolation ON {table} + USING (user_id = current_app_user_id()) + """) + + +def downgrade() -> None: + for table in ["pension_metadata", "pension_contributions"]: + op.execute(f"DROP POLICY IF EXISTS {table}_user_isolation ON {table}") + op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + op.drop_table("pension_contributions") + op.drop_table("pension_metadata") diff --git a/backend/alembic/versions/0008_add_state_pension_record.py b/backend/alembic/versions/0008_add_state_pension_record.py new file mode 100644 index 0000000..ff6f54e --- /dev/null +++ b/backend/alembic/versions/0008_add_state_pension_record.py @@ -0,0 +1,41 @@ +"""add state pension record table + +Revision ID: 0008 +Revises: 0007 +Create Date: 2026-04-27 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0008" +down_revision = "0007" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "state_pension_records", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("qualifying_years", sa.Integer, nullable=False), + sa.Column("checked_date", sa.Date, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_unique_constraint("uq_state_pension_records_user_id", "state_pension_records", ["user_id"]) + op.create_index("ix_state_pension_records_user_id", "state_pension_records", ["user_id"]) + + op.execute("ALTER TABLE state_pension_records ENABLE ROW LEVEL SECURITY") + op.execute(""" + CREATE POLICY state_pension_records_user_isolation ON state_pension_records + USING (user_id = current_app_user_id()) + """) + + +def downgrade() -> None: + op.execute("DROP POLICY IF EXISTS state_pension_records_user_isolation ON state_pension_records") + op.execute("ALTER TABLE state_pension_records DISABLE ROW LEVEL SECURITY") + op.drop_table("state_pension_records") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 3ab6a71..2aaa02c 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 439810c..e5ca38e 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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, ) diff --git a/backend/app/api/v1/pension.py b/backend/app/api/v1/pension.py new file mode 100644 index 0000000..6236b06 --- /dev/null +++ b/backend/app/api/v1/pension.py @@ -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)) diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index 96b7ef4..f9cb0bc 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -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", ] diff --git a/backend/app/db/models/pension.py b/backend/app/db/models/pension.py new file mode 100644 index 0000000..b522010 --- /dev/null +++ b/backend/app/db/models/pension.py @@ -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) diff --git a/backend/app/schemas/pension.py b/backend/app/schemas/pension.py new file mode 100644 index 0000000..7093636 --- /dev/null +++ b/backend/app/schemas/pension.py @@ -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 diff --git a/backend/app/schemas/tax.py b/backend/app/schemas/tax.py index a715fac..60b24bc 100644 --- a/backend/app/schemas/tax.py +++ b/backend/app/schemas/tax.py @@ -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 diff --git a/backend/app/services/pension_service.py b/backend/app/services/pension_service.py new file mode 100644 index 0000000..26b0f78 --- /dev/null +++ b/backend/app/services/pension_service.py @@ -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, + ) diff --git a/backend/app/services/tax_service.py b/backend/app/services/tax_service.py index 4d69a1d..5376af1 100644 --- a/backend/app/services/tax_service.py +++ b/backend/app/services/tax_service.py @@ -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), diff --git a/docker-compose.yml b/docker-compose.yml index 73d0ac1..4f1b928 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,6 +93,32 @@ services: timeout: 5s retries: 3 + test: + build: + context: ./backend + target: test + profiles: + - test + environment: + ADMIN_DATABASE_URL: "postgresql://finance_app:${DB_PASSWORD}@postgres:5432/postgres" + DATABASE_URL: "postgresql+asyncpg://finance_app:${DB_PASSWORD}@postgres:5432/financedb_test" + ENCRYPTION_KEY: "${ENCRYPTION_KEY}" + JWT_PRIVATE_KEY_FILE: "/run/secrets/jwt_private.pem" + JWT_PUBLIC_KEY_FILE: "/run/secrets/jwt_public.pem" + ENVIRONMENT: "development" + ALLOW_REGISTRATION: "true" + volumes: + - ./backend/tests:/app/tests + - ./backend/app:/app/app + - ./backend/alembic:/app/alembic + - ./backend/alembic.ini:/app/alembic.ini + - ./secrets:/run/secrets:ro + networks: + - backend_net + depends_on: + postgres: + condition: service_healthy + volumes: redis_data: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ff9e8f..c0f2201 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import PredictionsPage from "@/pages/predictions/PredictionsPage"; import SettingsPage from "@/pages/settings/SettingsPage"; import SubscriptionsPage from "@/pages/subscriptions/SubscriptionsPage"; import TaxPage from "@/pages/tax/TaxPage"; +import PensionsPage from "@/pages/pensions/PensionsPage"; function PrivateRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); @@ -56,6 +57,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/pensions.ts b/frontend/src/api/pensions.ts new file mode 100644 index 0000000..0c362cf --- /dev/null +++ b/frontend/src/api/pensions.ts @@ -0,0 +1,240 @@ +import { api } from "./client"; + +export type PensionType = "workplace_dc" | "workplace_db" | "sipp" | "lisa"; +export type ReliefType = "relief_at_source" | "net_pay" | "salary_sacrifice" | "none"; + +export interface PensionMetadata { + id: string; + account_id: string; + pension_type: PensionType; + provider_name: string | null; + scheme_name: string | null; + member_reference: string | null; + dob: string | null; + target_retirement_age: number | null; + assumed_growth_rate: number | null; + created_at: string; + updated_at: string; +} + +export interface PensionMetadataCreate { + pension_type: PensionType; + provider_name?: string | null; + scheme_name?: string | null; + member_reference?: string | null; + dob?: string | null; + target_retirement_age?: number | null; + assumed_growth_rate?: number | null; +} + +export interface PensionMetadataUpdate extends Partial {} + +export interface PensionContribution { + id: string; + pension_id: string; + contribution_date: string; + tax_year: number; + member_amount: number; + employer_amount: number; + relief_type: ReliefType; + gross_amount: number; + relief_amount: number; + notes: string | null; + created_at: string; +} + +export interface PensionContributionCreate { + contribution_date: string; + member_amount: number; + employer_amount?: number; + relief_type: ReliefType; + notes?: string | null; +} + +export interface PensionContributionUpdate { + contribution_date?: string; + member_amount?: number; + employer_amount?: number; + relief_type?: ReliefType; + notes?: string | null; +} + +export interface YtdSummary { + tax_year: number; + member_total: number; + employer_total: number; + gross_total: number; + relief_total: number; + contribution_count: number; +} + +export interface PensionAccount { + account_id: string; + account_name: string; + current_balance: number; + currency: string; + color: string; + metadata: PensionMetadata | null; + ytd: YtdSummary | null; +} + +export interface CarryForwardYear { + tax_year: number; + standard_allowance: number; + contributions: number; + unused: number; +} + +export interface AllowanceSummary { + tax_year: number; + standard_allowance: number; + contributions_total: number; + remaining: number; + carry_forward: CarryForwardYear[]; + carry_forward_total: number; + total_available: number; + relief_ras_total: number; + relief_higher_rate_claimable: number; + relief_additional_rate_claimable: number; +} + +export interface StatePensionResponse { + id: string; + qualifying_years: number; + checked_date: string | null; + weekly_amount: number; + annual_amount: number; + is_full_pension: boolean; + years_to_full: number; + state_pension_age: number; +} + +export interface StatePensionCreate { + qualifying_years: number; + checked_date?: string | null; +} + +export interface ProjectionScenario { + label: string; + growth_rate: number; + projected_pot: number; + annual_drawdown_4pct: number; + annual_drawdown_3pct: number; +} + +export interface ChartDataPoint { + year: number; + pot_2pct: number; + pot_5pct: number; + pot_8pct: number; +} + +export interface RetirementProjection { + account_id: string; + account_name: string; + current_balance: number; + years_to_retirement: number; + target_retirement_age: number; + scenarios: ProjectionScenario[]; + state_pension_annual: number | null; + state_pension_age: number; + chart_data: ChartDataPoint[]; +} + +export async function getStatePension(): Promise { + const r = await api.get("/pensions/state-pension"); + return r.data; +} + +export async function upsertStatePension(data: StatePensionCreate): Promise { + const r = await api.post("/pensions/state-pension", data); + return r.data; +} + +export async function getRetirementProjection(accountId: string): Promise { + const r = await api.get(`/pensions/${accountId}/projection`); + return r.data; +} + +export async function getPensionsAllowance(taxYear?: number): Promise { + const r = await api.get("/pensions/allowance", { params: taxYear ? { tax_year: taxYear } : {} }); + return r.data; +} + +export async function listPensionAccounts(): Promise { + const r = await api.get("/pensions"); + return r.data; +} + +export async function getPensionsSummary(taxYear?: number): Promise { + const r = await api.get("/pensions/summary", { params: taxYear ? { tax_year: taxYear } : {} }); + return r.data; +} + +export async function getPensionMetadata(accountId: string): Promise { + const r = await api.get(`/pensions/${accountId}/metadata`); + return r.data; +} + +export async function createPensionMetadata(accountId: string, data: PensionMetadataCreate): Promise { + const r = await api.post(`/pensions/${accountId}/metadata`, data); + return r.data; +} + +export async function updatePensionMetadata(accountId: string, data: PensionMetadataUpdate): Promise { + const r = await api.put(`/pensions/${accountId}/metadata`, data); + return r.data; +} + +export async function listContributions(accountId: string, taxYear?: number): Promise { + const r = await api.get(`/pensions/${accountId}/contributions`, { + params: taxYear ? { tax_year: taxYear } : {}, + }); + return r.data; +} + +export async function addContribution(accountId: string, data: PensionContributionCreate): Promise { + const r = await api.post(`/pensions/${accountId}/contributions`, data); + return r.data; +} + +export async function updateContribution( + accountId: string, + contributionId: string, + data: PensionContributionUpdate, +): Promise { + const r = await api.put(`/pensions/${accountId}/contributions/${contributionId}`, data); + return r.data; +} + +export async function deleteContribution(accountId: string, contributionId: string): Promise { + await api.delete(`/pensions/${accountId}/contributions/${contributionId}`); +} + +export interface LisaTaxYearBreakdown { + tax_year: number; + contributions: number; + bonus_expected: number; + limit_remaining: number; + limit_used_pct: number; +} + +export interface LisaSummary { + account_id: string; + account_name: string; + tax_year_breakdown: LisaTaxYearBreakdown[]; + current_year_contributions: number; + current_year_bonus_expected: number; + current_year_limit_remaining: number; + total_contributions: number; + total_bonus_expected: number; + account_opened_date: string; + withdrawal_penalty_amount: number; + withdrawal_penalty_pct: number; + penalty_warning: boolean; +} + +export async function getLisaSummary(accountId: string): Promise { + const r = await api.get(`/pensions/${accountId}/lisa-summary`); + return r.data; +} diff --git a/frontend/src/api/tax.ts b/frontend/src/api/tax.ts index 9905abe..7b87559 100644 --- a/frontend/src/api/tax.ts +++ b/frontend/src/api/tax.ts @@ -190,6 +190,16 @@ export interface TaxReport { band_breakdown: BandBreakdown[]; dividend_transactions: DividendTransactionItem[]; }; + pensions: { + net_pay_total: string; + salary_sacrifice_total: string; + ras_gross_total: string; + higher_rate_claimable: string; + additional_rate_claimable: string; + annual_allowance_used: string; + annual_allowance_remaining: string; + standard_allowance: string; + } | null; summary: { total_liability: string; total_withheld: string; diff --git a/frontend/src/components/layout/MobileNav.tsx b/frontend/src/components/layout/MobileNav.tsx index b99e97c..b6221ac 100644 --- a/frontend/src/components/layout/MobileNav.tsx +++ b/frontend/src/components/layout/MobileNav.tsx @@ -2,7 +2,7 @@ import { Link, useLocation } from "react-router-dom"; import { cn } from "@/utils/cn"; import { LayoutDashboard, CreditCard, ArrowLeftRight, - PiggyBank, TrendingUp, BarChart3, Sparkles, Settings, Repeat, Receipt, + PiggyBank, TrendingUp, BarChart3, Sparkles, Settings, Repeat, Receipt, ShieldCheck, } from "lucide-react"; const NAV = [ @@ -14,6 +14,7 @@ const NAV = [ { href: "/investments", icon: TrendingUp, label: "Invest" }, { href: "/reports", icon: BarChart3, label: "Reports" }, { href: "/tax", icon: Receipt, label: "Tax" }, + { href: "/pensions", icon: ShieldCheck, label: "Pensions" }, { href: "/predictions", icon: Sparkles, label: "Predict" }, { href: "/settings", icon: Settings, label: "Settings" }, ]; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 35816a2..986217b 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -15,6 +15,7 @@ import { Coins, Repeat, Receipt, + ShieldCheck, } from "lucide-react"; const navItems = [ @@ -26,6 +27,7 @@ const navItems = [ { href: "/investments", icon: TrendingUp, label: "Investments" }, { href: "/reports", icon: BarChart3, label: "Reports" }, { href: "/tax", icon: Receipt, label: "Tax" }, + { href: "/pensions", icon: ShieldCheck, label: "Pensions" }, { href: "/predictions", icon: Sparkles, label: "Predictions" }, { href: "/settings", icon: Settings, label: "Settings" }, ]; diff --git a/frontend/src/pages/accounts/AccountFormModal.tsx b/frontend/src/pages/accounts/AccountFormModal.tsx index 9d4cd4f..1c849cf 100644 --- a/frontend/src/pages/accounts/AccountFormModal.tsx +++ b/frontend/src/pages/accounts/AccountFormModal.tsx @@ -21,18 +21,19 @@ const COLORS = ["#6366f1", "#22c55e", "#0ea5e9", "#f59e0b", "#ec4899", "#ef4444" interface Props { account?: Account; + defaultType?: string; onClose: () => void; onSubmit: (data: AccountCreate) => void; isLoading: boolean; } -export default function AccountFormModal({ account, onClose, onSubmit, isLoading }: Props) { +export default function AccountFormModal({ account, defaultType, onClose, onSubmit, isLoading }: Props) { const isEdit = !!account; const [form, setForm] = useState({ name: account?.name ?? "", institution: account?.institution ?? "", - type: account?.type ?? "checking", + type: account?.type ?? defaultType ?? "checking", currency: account?.currency ?? "GBP", opening_balance: account ? String(account.current_balance) : "0", credit_limit: account?.credit_limit != null ? String(account.credit_limit) : "", diff --git a/frontend/src/pages/pensions/AnnualAllowanceCard.tsx b/frontend/src/pages/pensions/AnnualAllowanceCard.tsx new file mode 100644 index 0000000..4f09b55 --- /dev/null +++ b/frontend/src/pages/pensions/AnnualAllowanceCard.tsx @@ -0,0 +1,206 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft, ChevronRight, Info } from "lucide-react"; +import { getPensionsAllowance } from "@/api/pensions"; +import { formatCurrency } from "@/utils/currency"; + +function taxYearDisplay(year: number): string { + return `${year - 1}/${String(year).slice(2)}`; +} + +function currentTaxYear(): number { + const today = new Date(); + return today.getMonth() > 3 || (today.getMonth() === 3 && today.getDate() >= 6) + ? today.getFullYear() + : today.getFullYear(); +} + +interface Props { + initialTaxYear: number; +} + +export default function AnnualAllowanceCard({ initialTaxYear }: Props) { + const [taxYear, setTaxYear] = useState(initialTaxYear); + const maxYear = currentTaxYear(); + + const { data, isLoading } = useQuery({ + queryKey: ["pensions-allowance", taxYear], + queryFn: () => getPensionsAllowance(taxYear), + }); + + const usedPct = data ? Math.min(100, (Number(data.contributions_total) / Number(data.standard_allowance)) * 100) : 0; + const isWarning = usedPct >= 60 && usedPct < 90; + const isDanger = usedPct >= 90; + const isExceeded = data ? Number(data.contributions_total) > Number(data.standard_allowance) : false; + + const barColour = isDanger ? "bg-red-500" : isWarning ? "bg-amber-500" : "bg-green-500"; + + const hasCarryForward = data ? data.carry_forward.some((y) => y.unused > 0) : false; + const hasRelief = data ? Number(data.relief_ras_total) > 0 : false; + const saDeadlineYear = taxYear + 1; + + return ( +
+ {/* Header + year selector */} +
+

Annual Allowance

+
+ + + {taxYearDisplay(taxYear)} + + +
+
+ + {isLoading ? ( +
+
+
+ {[0, 1, 2].map((i) =>
)} +
+
+ ) : data ? ( + <> + {/* Warning / exceeded banners */} + {isExceeded && ( +
+ Annual allowance exceeded — tax charge may apply on contributions above £{Number(data.standard_allowance).toLocaleString()}. +
+ )} + {!isExceeded && isDanger && ( +
+ Approaching annual allowance limit — {formatCurrency(Number(data.remaining), "GBP")} remaining. +
+ )} + + {/* Progress bar */} +
+
+ {formatCurrency(Number(data.contributions_total), "GBP")} used + £{Number(data.standard_allowance).toLocaleString()} limit +
+
+
+
+
+ + {/* Three stat pills */} +
+
+

Used this year

+

+ {formatCurrency(Number(data.contributions_total), "GBP")} +

+
+
+

Remaining

+

+ {formatCurrency(Number(data.remaining), "GBP")} +

+
+
+

Carry-forward

+

+ {formatCurrency(Number(data.carry_forward_total), "GBP")} +

+
+
+ + {/* Carry-forward breakdown */} + {hasCarryForward && ( +
+
+

Carry-forward available

+ + + +
+ + + + + + + + + + + {data.carry_forward.map((yr) => ( + + + + + + + ))} + + + + + +
Tax yearAllowanceUsedUnused
{taxYearDisplay(yr.tax_year)} + {formatCurrency(Number(yr.standard_allowance), "GBP")} + + {Number(yr.contributions) > 0 ? formatCurrency(Number(yr.contributions), "GBP") : "—"} + 0 ? "text-green-500" : "text-muted-foreground"}`}> + {Number(yr.unused) > 0 ? formatCurrency(Number(yr.unused), "GBP") : "—"} +
+ Total available (standard + carry-forward) + + {formatCurrency(Number(data.total_available), "GBP")} +
+

Oldest unused year must be applied first per HMRC rules.

+
+ )} + + {/* Tax relief summary */} + {hasRelief && ( +
+

+ Tax relief — {taxYearDisplay(taxYear)} +

+
+
+ Relief at source (auto-claimed) + +{formatCurrency(Number(data.relief_ras_total), "GBP")} +
+ {Number(data.relief_higher_rate_claimable) > 0 && ( +
+ Higher-rate relief (40% band, via SA) + ~{formatCurrency(Number(data.relief_higher_rate_claimable), "GBP")} +
+ )} + {Number(data.relief_additional_rate_claimable) > 0 && ( +
+ Additional-rate top-up (45% band, via SA) + ~{formatCurrency(Number(data.relief_additional_rate_claimable), "GBP")} +
+ )} +
+ {Number(data.relief_higher_rate_claimable) > 0 && ( +

+ Higher/additional rate estimates apply only to relief-at-source contributions. Claim via Self-Assessment by 31 Jan {saDeadlineYear}. +

+ )} +
+ )} + + ) : null} +
+ ); +} diff --git a/frontend/src/pages/pensions/ContributionFormModal.tsx b/frontend/src/pages/pensions/ContributionFormModal.tsx new file mode 100644 index 0000000..edd3351 --- /dev/null +++ b/frontend/src/pages/pensions/ContributionFormModal.tsx @@ -0,0 +1,245 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { X, Info } from "lucide-react"; +import { + addContribution, + updateContribution, + type PensionContribution, + type PensionContributionCreate, + type ReliefType, +} from "@/api/pensions"; +import { formatCurrency } from "@/utils/currency"; + +const RELIEF_TYPES: { value: ReliefType; label: string; detail: string }[] = [ + { + value: "relief_at_source", + label: "Relief at source", + detail: "You pay 80%, the scheme claims 20% from HMRC automatically. Common for SIPPs & personal pensions.", + }, + { + value: "net_pay", + label: "Net pay arrangement", + detail: "Contributions deducted before tax via payroll. Relief applied at your marginal rate automatically.", + }, + { + value: "salary_sacrifice", + label: "Salary sacrifice", + detail: "Employer deducts contribution as gross pay. Saves income tax and National Insurance.", + }, + { + value: "none", + label: "No relief", + detail: "No tax relief (e.g. employer-only contribution already outside your allowance, or LISA government bonus tracked separately).", + }, +]; + +function computeGrossAndRelief(memberAmount: number, reliefType: ReliefType): { gross: number; relief: number } { + if (reliefType === "relief_at_source") { + const gross = memberAmount / 0.8; + return { gross, relief: gross - memberAmount }; + } + if (reliefType === "net_pay" || reliefType === "salary_sacrifice") { + return { gross: memberAmount, relief: memberAmount * 0.2 }; + } + return { gross: memberAmount, relief: 0 }; +} + +interface Props { + accountId: string; + currency: string; + existing?: PensionContribution; + onClose: () => void; +} + +export default function ContributionFormModal({ accountId, currency, existing, onClose }: Props) { + const qc = useQueryClient(); + + const [date, setDate] = useState(existing?.contribution_date ?? new Date().toISOString().slice(0, 10)); + const [memberAmount, setMemberAmount] = useState(existing ? String(existing.member_amount) : ""); + const [employerAmount, setEmployerAmount] = useState(existing ? String(existing.employer_amount) : ""); + const [reliefType, setReliefType] = useState(existing?.relief_type ?? "relief_at_source"); + const [notes, setNotes] = useState(existing?.notes ?? ""); + const [showReliefInfo, setShowReliefInfo] = useState(false); + + const member = parseFloat(memberAmount) || 0; + const { gross, relief } = computeGrossAndRelief(member, reliefType); + + const mutation = useMutation({ + mutationFn: (data: PensionContributionCreate) => + existing + ? updateContribution(accountId, existing.id, data) + : addContribution(accountId, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pension-contributions", accountId] }); + qc.invalidateQueries({ queryKey: ["pensions"] }); + qc.invalidateQueries({ queryKey: ["pensions-summary"] }); + onClose(); + }, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!date || member <= 0 && (parseFloat(employerAmount) || 0) <= 0) return; + mutation.mutate({ + contribution_date: date, + member_amount: member, + employer_amount: parseFloat(employerAmount) || 0, + relief_type: reliefType, + notes: notes || null, + }); + } + + return ( +
+
+
+

+ {existing ? "Edit contribution" : "Add contribution"} +

+ +
+ +
+ {/* Date */} +
+ + setDate(e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + {/* Amounts */} +
+
+ + setMemberAmount(e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + setEmployerAmount(e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + {/* Relief type */} +
+
+ + +
+ {showReliefInfo && ( +
+ {RELIEF_TYPES.map((rt) => ( +
+ {rt.label}: {rt.detail} +
+ ))} +
+ )} +
+ {RELIEF_TYPES.map((rt) => ( + + ))} +
+
+ + {/* Computed relief preview */} + {member > 0 && reliefType !== "none" && ( +
+
+ Gross contribution + {formatCurrency(gross, currency)} +
+
+ Basic rate relief (20%) + +{formatCurrency(relief, currency)} +
+ {(reliefType === "relief_at_source") && ( +

+ Higher/additional rate taxpayers can claim extra relief via Self-Assessment. +

+ )} +
+ )} + + {/* Notes */} +
+ + setNotes(e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + {mutation.isError && ( +

Failed to save. Please try again.

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/pensions/ContributionHistoryTable.tsx b/frontend/src/pages/pensions/ContributionHistoryTable.tsx new file mode 100644 index 0000000..aaf32d1 --- /dev/null +++ b/frontend/src/pages/pensions/ContributionHistoryTable.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Pencil, Trash2 } from "lucide-react"; +import { deleteContribution, type PensionContribution, type ReliefType } from "@/api/pensions"; +import { formatCurrency } from "@/utils/currency"; +import ContributionFormModal from "./ContributionFormModal"; + +const RELIEF_LABELS: Record = { + relief_at_source: "Relief at source", + net_pay: "Net pay", + salary_sacrifice: "Salary sacrifice", + none: "No relief", +}; + +interface Props { + accountId: string; + contributions: PensionContribution[]; +} + +function groupByTaxYear(contributions: PensionContribution[]): [number, PensionContribution[]][] { + const map = new Map(); + for (const c of contributions) { + const group = map.get(c.tax_year) ?? []; + group.push(c); + map.set(c.tax_year, group); + } + return Array.from(map.entries()).sort(([a], [b]) => b - a); +} + +function taxYearDisplay(year: number): string { + return `${year - 1}/${String(year).slice(2)}`; +} + +export default function ContributionHistoryTable({ accountId, contributions }: Props) { + const qc = useQueryClient(); + const [editing, setEditing] = useState(null); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteContribution(accountId, id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pension-contributions", accountId] }); + qc.invalidateQueries({ queryKey: ["pensions"] }); + qc.invalidateQueries({ queryKey: ["pensions-summary"] }); + }, + }); + + if (contributions.length === 0) { + return ( +

+ No contributions recorded yet. +

+ ); + } + + const currency = "GBP"; + const grouped = groupByTaxYear(contributions); + + return ( + <> +
+ {grouped.map(([taxYear, rows]) => { + const memberTotal = rows.reduce((s, r) => s + Number(r.member_amount), 0); + const employerTotal = rows.reduce((s, r) => s + Number(r.employer_amount), 0); + const reliefTotal = rows.reduce((s, r) => s + Number(r.relief_amount), 0); + + return ( +
+
+ + {taxYearDisplay(taxYear)} tax year + +
+ + Member {formatCurrency(memberTotal, currency)} + {employerTotal > 0 && ` · Employer ${formatCurrency(employerTotal, currency)}`} + {reliefTotal > 0 && ` · Relief ${formatCurrency(reliefTotal, currency)}`} + +
+ + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
DateMemberEmployerGrossReliefType +
{row.contribution_date} + {formatCurrency(Number(row.member_amount), currency)} + + {Number(row.employer_amount) > 0 ? formatCurrency(Number(row.employer_amount), currency) : "—"} + + {formatCurrency(Number(row.gross_amount), currency)} + + {Number(row.relief_amount) > 0 ? `+${formatCurrency(Number(row.relief_amount), currency)}` : "—"} + + + {RELIEF_LABELS[row.relief_type]} + + +
+ + +
+
+
+ ); + })} +
+ + {editing && ( + setEditing(null)} + /> + )} + + ); +} diff --git a/frontend/src/pages/pensions/LisaInfoCard.tsx b/frontend/src/pages/pensions/LisaInfoCard.tsx new file mode 100644 index 0000000..ee457b2 --- /dev/null +++ b/frontend/src/pages/pensions/LisaInfoCard.tsx @@ -0,0 +1,135 @@ +import { useQuery } from "@tanstack/react-query"; +import { getLisaSummary } from "@/api/pensions"; +import { formatCurrency } from "@/utils/currency"; +import { AlertTriangle, Gift, Info } from "lucide-react"; + +function taxYearDisplay(year: number): string { + return `${year - 1}/${String(year).slice(2)}`; +} + +interface Props { + accountId: string; +} + +export default function LisaInfoCard({ accountId }: Props) { + const { data, isLoading, isError } = useQuery({ + queryKey: ["lisa-summary", accountId], + queryFn: () => getLisaSummary(accountId), + retry: false, + }); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if (isError || !data) { + return null; + } + + const usedPct = Math.min(100, (data.current_year_contributions / 4000) * 100); + const barColour = + usedPct >= 100 ? "bg-green-500" : usedPct >= 75 ? "bg-amber-500" : "bg-primary"; + + return ( +
+ {/* Current year contribution meter */} +
+
+ +

LISA — Current Year

+
+ +
+
+ {formatCurrency(data.current_year_contributions, "GBP")} contributed + {formatCurrency(4000, "GBP")} limit +
+
+
+
+
+ + +{formatCurrency(data.current_year_bonus_expected, "GBP")} bonus + + + {formatCurrency(data.current_year_limit_remaining, "GBP")} remaining + +
+
+ +
+
+

Total contributed

+

+ {formatCurrency(data.total_contributions, "GBP")} +

+
+
+

Total bonus expected

+

+ +{formatCurrency(data.total_bonus_expected, "GBP")} +

+
+
+
+ + {/* Per-year breakdown */} + {data.tax_year_breakdown.length > 0 && ( +
+

+ Year-by-year breakdown +

+
+ {data.tax_year_breakdown.map((row) => ( +
+ {taxYearDisplay(row.tax_year)} + {formatCurrency(row.contributions, "GBP")} + +{formatCurrency(row.bonus_expected, "GBP")} +
+
+
+
+ ))} +
+
+ )} + + {/* Withdrawal penalty warning */} +
+
+ +

Withdrawal penalty

+
+

+ Withdrawing before age 60 (except for first-home purchase up to £450k) incurs a{" "} + 25% penalty on the full withdrawal amount. + Because the fund includes the government bonus, the effective loss can exceed the bonus itself. +

+

+ If you withdrew the full balance today, the penalty would be approximately{" "} + {formatCurrency(data.withdrawal_penalty_amount, "GBP")}. +

+
+ + {/* Key facts */} +
+ + + LISA contributions attract a 25% government bonus (max £1,000/yr). Eligible for first-home + purchase (property up to £450k) or retirement from age 60. + +
+
+ ); +} diff --git a/frontend/src/pages/pensions/PensionAccountCard.tsx b/frontend/src/pages/pensions/PensionAccountCard.tsx new file mode 100644 index 0000000..e3f92dc --- /dev/null +++ b/frontend/src/pages/pensions/PensionAccountCard.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronDown, ChevronUp, Settings2, Plus } from "lucide-react"; +import { listContributions, type PensionAccount } from "@/api/pensions"; +import { formatCurrency } from "@/utils/currency"; +import ContributionHistoryTable from "./ContributionHistoryTable"; +import ContributionFormModal from "./ContributionFormModal"; +import LisaInfoCard from "./LisaInfoCard"; + +const PENSION_TYPE_LABELS: Record = { + workplace_dc: "Workplace DC", + workplace_db: "Workplace DB", + sipp: "SIPP", + lisa: "Lifetime ISA", +}; + +interface Props { + account: PensionAccount; + onEditMetadata: () => void; +} + +function currentTaxYear(): number { + const today = new Date(); + return today.getMonth() > 3 || (today.getMonth() === 3 && today.getDate() >= 6) + ? today.getFullYear() + : today.getFullYear(); +} + +export default function PensionAccountCard({ account, onEditMetadata }: Props) { + const [expanded, setExpanded] = useState(false); + const [showAddContribution, setShowAddContribution] = useState(false); + const taxYear = currentTaxYear(); + + const { data: contributions = [] } = useQuery({ + queryKey: ["pension-contributions", account.account_id], + queryFn: () => listContributions(account.account_id), + enabled: expanded, + }); + + const meta = account.metadata; + const ytd = account.ytd; + + return ( +
+ {/* Card header */} +
+
+
+
+
+ {account.account_name} + {meta && ( + + {PENSION_TYPE_LABELS[meta.pension_type] ?? meta.pension_type} + + )} +
+ {meta?.provider_name && ( +

{meta.provider_name}

+ )} +
+
+ +
+
+

+ {formatCurrency(Number(account.current_balance), account.currency)} +

+

current value

+
+
+ + +
+
+
+ + {/* YTD summary strip */} + {ytd && ytd.contribution_count > 0 && ( +
+ + Member: {formatCurrency(ytd.member_total, account.currency)} + + {ytd.employer_total > 0 && ( + + Employer: {formatCurrency(ytd.employer_total, account.currency)} + + )} + + Relief: +{formatCurrency(ytd.relief_total, account.currency)} + + {taxYear - 1}/{String(taxYear).slice(2)} tax year +
+ )} + + {/* No metadata prompt */} + {!meta && ( +
+ +
+ )} + + {/* Expanded section: contribution history + LISA info */} + {expanded && ( +
+
+
+

Contribution history

+ +
+ + {meta?.pension_type === "lisa" && ( + + )} +
+
+ )} + + {showAddContribution && ( + setShowAddContribution(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/pensions/PensionMetadataModal.tsx b/frontend/src/pages/pensions/PensionMetadataModal.tsx new file mode 100644 index 0000000..3466a81 --- /dev/null +++ b/frontend/src/pages/pensions/PensionMetadataModal.tsx @@ -0,0 +1,219 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { + createPensionMetadata, + updatePensionMetadata, + type PensionAccount, + type PensionMetadataCreate, + type PensionType, +} from "@/api/pensions"; + +const PENSION_TYPES: { value: PensionType; label: string; description: string }[] = [ + { value: "workplace_dc", label: "Workplace DC", description: "Defined contribution — pot depends on contributions & investment growth" }, + { value: "workplace_db", label: "Workplace DB", description: "Defined benefit — guaranteed income based on salary & service" }, + { value: "sipp", label: "SIPP", description: "Self-Invested Personal Pension — full control over investments" }, + { value: "lisa", label: "Lifetime ISA", description: "25% government bonus on contributions (max £4,000/yr); restricted access" }, +]; + +interface Props { + account: PensionAccount; + onClose: () => void; +} + +export default function PensionMetadataModal({ account, onClose }: Props) { + const qc = useQueryClient(); + const existing = account.metadata; + + const [form, setForm] = useState({ + pension_type: existing?.pension_type ?? "workplace_dc", + provider_name: existing?.provider_name ?? "", + scheme_name: existing?.scheme_name ?? "", + member_reference: existing?.member_reference ?? "", + dob: existing?.dob ?? "", + target_retirement_age: existing?.target_retirement_age ?? null, + assumed_growth_rate: existing?.assumed_growth_rate ?? null, + }); + + const mutation = useMutation({ + mutationFn: (data: PensionMetadataCreate) => + existing + ? updatePensionMetadata(account.account_id, data) + : createPensionMetadata(account.account_id, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pensions"] }); + onClose(); + }, + }); + + const set = (field: keyof PensionMetadataCreate, value: unknown) => + setForm((f) => ({ ...f, [field]: value })); + + return ( +
+
+
+

+ {existing ? "Edit pension details" : "Add pension details"} — {account.account_name} +

+ +
+ +
{ + e.preventDefault(); + const data: PensionMetadataCreate = { + ...form, + provider_name: form.provider_name || null, + scheme_name: form.scheme_name || null, + member_reference: form.member_reference || null, + dob: form.dob || null, + assumed_growth_rate: form.assumed_growth_rate ?? null, + target_retirement_age: form.target_retirement_age ?? null, + }; + mutation.mutate(data); + }} + className="p-5 space-y-4" + > + {/* Pension type */} +
+ +
+ {PENSION_TYPES.map((pt) => ( + + ))} +
+
+ + {/* Provider & scheme */} +
+
+ + set("provider_name", e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + set("scheme_name", e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + {/* Member reference */} +
+ + set("member_reference", e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + {/* DOB + target retirement age */} +
+
+ + set("dob", e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + set("target_retirement_age", e.target.value ? Number(e.target.value) : null)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +

Min age rises to 57 in 2028

+
+
+ + {/* Assumed growth rate */} +
+ +
+ {[ + { label: "Cautious 2%", value: 0.02 }, + { label: "Moderate 5%", value: 0.05 }, + { label: "Growth 8%", value: 0.08 }, + ].map((opt) => ( + + ))} +
+
+ + {mutation.isError && ( +

Failed to save. Please try again.

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/pensions/PensionsPage.tsx b/frontend/src/pages/pensions/PensionsPage.tsx new file mode 100644 index 0000000..05aaf9f --- /dev/null +++ b/frontend/src/pages/pensions/PensionsPage.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { ShieldCheck, Plus, Loader2, AlertCircle } from "lucide-react"; +import { listPensionAccounts, getPensionsSummary, type PensionAccount } from "@/api/pensions"; +import { createAccount, type AccountCreate } from "@/api/accounts"; +import { formatCurrency } from "@/utils/currency"; +import PensionAccountCard from "./PensionAccountCard"; +import PensionMetadataModal from "./PensionMetadataModal"; +import AnnualAllowanceCard from "./AnnualAllowanceCard"; +import StatePensionWidget from "./StatePensionWidget"; +import RetirementProjectionCard from "./RetirementProjectionCard"; +import AccountFormModal from "@/pages/accounts/AccountFormModal"; + +function currentTaxYear(): number { + const today = new Date(); + return today.getMonth() > 3 || (today.getMonth() === 3 && today.getDate() >= 6) + ? today.getFullYear() + : today.getFullYear(); +} + +function taxYearDisplay(year: number): string { + return `${year - 1}/${String(year).slice(2)}`; +} + +function SummaryCard({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +export default function PensionsPage() { + const qc = useQueryClient(); + const [metadataTarget, setMetadataTarget] = useState(null); + const [showAddAccount, setShowAddAccount] = useState(false); + const taxYear = currentTaxYear(); + + const { data: accounts = [], isLoading, isError } = useQuery({ + queryKey: ["pensions"], + queryFn: listPensionAccounts, + }); + + const { data: summary } = useQuery({ + queryKey: ["pensions-summary", taxYear], + queryFn: () => getPensionsSummary(taxYear), + }); + + const createMutation = useMutation({ + mutationFn: createAccount, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pensions"] }); + qc.invalidateQueries({ queryKey: ["accounts"] }); + qc.invalidateQueries({ queryKey: ["net-worth"] }); + setShowAddAccount(false); + }, + }); + + const totalBalance = accounts.reduce((sum, a) => sum + Number(a.current_balance), 0); + const primaryCurrency = accounts[0]?.currency ?? "GBP"; + + if (isLoading) { + return ( +
+ + Loading pensions… +
+ ); + } + + if (isError) { + return ( +
+ + Failed to load pension data. +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

Pensions

+
+ +
+ + {/* Summary cards */} + {accounts.length > 0 && ( +
+ + + + +
+ )} + + {/* Pension account cards */} + {accounts.length === 0 ? ( +
+ +

No pension accounts yet

+

+ Add your first pension to start tracking contributions and tax relief. +

+ +
+ ) : ( +
+ {accounts.map((account) => ( + setMetadataTarget(account)} + /> + ))} +
+ )} + + {accounts.length > 0 && ( + + )} + + {accounts.length > 0 && ( + <> +

Retirement Planning

+ +
+ {accounts.map((account) => ( + setMetadataTarget(account)} + /> + ))} +
+ + )} + + {showAddAccount && ( + setShowAddAccount(false)} + onSubmit={(data: AccountCreate) => createMutation.mutate(data)} + isLoading={createMutation.isPending} + /> + )} + + {metadataTarget && ( + setMetadataTarget(null)} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/pensions/RetirementProjectionCard.tsx b/frontend/src/pages/pensions/RetirementProjectionCard.tsx new file mode 100644 index 0000000..7ee803e --- /dev/null +++ b/frontend/src/pages/pensions/RetirementProjectionCard.tsx @@ -0,0 +1,200 @@ +import { useQuery } from "@tanstack/react-query"; +import { getRetirementProjection } from "@/api/pensions"; +import { formatCurrency } from "@/utils/currency"; +import { TOOLTIP_STYLE } from "@/utils/chartTheme"; +import { Settings2, TrendingUp } from "lucide-react"; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; + +const SCENARIO_COLOURS = { + pot_2pct: "hsl(var(--muted-foreground))", + pot_5pct: "hsl(var(--primary))", + pot_8pct: "#22c55e", +}; + +const SCENARIO_LABELS = { + pot_2pct: "Conservative 2%", + pot_5pct: "Moderate 5%", + pot_8pct: "Growth 8%", +}; + +function formatYAxis(value: number): string { + if (value >= 1_000_000) return `£${(value / 1_000_000).toFixed(1)}m`; + if (value >= 1_000) return `£${(value / 1_000).toFixed(0)}k`; + return `£${value}`; +} + +interface Props { + accountId: string; + accountName: string; + hasMetadata: boolean; + onEditMetadata: () => void; +} + +export default function RetirementProjectionCard({ accountId, accountName, hasMetadata, onEditMetadata }: Props) { + const { data, isLoading, error } = useQuery({ + queryKey: ["pension-projection", accountId], + queryFn: () => getRetirementProjection(accountId), + enabled: hasMetadata, + retry: false, + }); + + if (!hasMetadata) { + return ( +
+ +

{accountName}

+

+ Add your date of birth and target retirement age to see a retirement projection. +

+ +
+ ); + } + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if (error || !data) { + const msg = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail; + return ( +
+

{accountName}

+

+ {msg ?? "Could not load projection. Ensure date of birth and target retirement age are set in pension details."} +

+ +
+ ); + } + + const moderateScenario = data.scenarios.find((s) => s.growth_rate === 0.05) ?? data.scenarios[1]; + + return ( +
+ {/* Header */} +
+
+

{data.account_name}

+

+ {data.years_to_retirement} years to retirement · target age {data.target_retirement_age} +

+
+

+ Current: {formatCurrency(Number(data.current_balance), "GBP")} +

+
+ + {/* Scenario pills */} +
+ {data.scenarios.map((s) => ( +
+

{s.label}

+

+ {formatCurrency(Number(s.projected_pot), "GBP")} +

+

+ {formatCurrency(Number(s.annual_drawdown_4pct), "GBP")}/yr at 4% +

+ {data.state_pension_annual && ( +

+ +{formatCurrency(Number(data.state_pension_annual), "GBP")} SP +

+ )} +
+ ))} +
+ + {/* Chart */} + {data.chart_data.length > 1 && ( +
+ + + + {(["pot_2pct", "pot_5pct", "pot_8pct"] as const).map((key) => ( + + + + + ))} + + + + + [ + formatCurrency(value, "GBP"), + SCENARIO_LABELS[name as keyof typeof SCENARIO_LABELS] ?? name, + ]} + /> + SCENARIO_LABELS[value as keyof typeof SCENARIO_LABELS] ?? value} + wrapperStyle={{ fontSize: 11, paddingTop: 8 }} + /> + {(["pot_2pct", "pot_5pct", "pot_8pct"] as const).map((key) => ( + + ))} + + +
+ )} + + {/* State pension note */} + {data.state_pension_annual && ( +

+ State Pension adds {formatCurrency(Number(data.state_pension_annual), "GBP")}/yr from age {data.state_pension_age}, + bringing moderate scenario total to{" "} + + {formatCurrency(Number(moderateScenario.annual_drawdown_4pct) + Number(data.state_pension_annual), "GBP")}/yr + . +

+ )} + +

+ Projections are estimates based on assumed growth rates. Not financial advice. +

+
+ ); +} diff --git a/frontend/src/pages/pensions/StatePensionWidget.tsx b/frontend/src/pages/pensions/StatePensionWidget.tsx new file mode 100644 index 0000000..0d08a61 --- /dev/null +++ b/frontend/src/pages/pensions/StatePensionWidget.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { ExternalLink, Pencil, Check, X } from "lucide-react"; +import { getStatePension, upsertStatePension } from "@/api/pensions"; +import { formatCurrency } from "@/utils/currency"; + +export default function StatePensionWidget() { + const qc = useQueryClient(); + const [editing, setEditing] = useState(false); + const [years, setYears] = useState(""); + const [checkedDate, setCheckedDate] = useState(""); + + const { data, isLoading } = useQuery({ + queryKey: ["state-pension"], + queryFn: getStatePension, + retry: false, + }); + + const mutation = useMutation({ + mutationFn: upsertStatePension, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["state-pension"] }); + qc.invalidateQueries({ queryKey: ["pension-projection"] }); + setEditing(false); + }, + }); + + function startEdit() { + setYears(data ? String(data.qualifying_years) : ""); + setCheckedDate(data?.checked_date ?? ""); + setEditing(true); + } + + function handleSave() { + const y = parseInt(years); + if (isNaN(y) || y < 0 || y > 50) return; + mutation.mutate({ qualifying_years: y, checked_date: checkedDate || null }); + } + + return ( +
+
+

State Pension

+
+ + Check on gov.uk + + {!editing && ( + + )} +
+
+ + {editing ? ( +
+
+
+ + setYears(e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="e.g. 32" + autoFocus + /> +

35 years needed for full State Pension

+
+
+ + setCheckedDate(e.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ {mutation.isError && ( +

Failed to save. Try again.

+ )} +
+ + +
+
+ ) : isLoading ? ( +
+
+
+
+ ) : data ? ( +
+ {/* NI record progress */} +
+
+ {data.qualifying_years} qualifying years + 35 needed for full pension +
+
+
+
+ {!data.is_full_pension && ( +

{data.years_to_full} more {data.years_to_full === 1 ? "year" : "years"} needed for full pension

+ )} +
+ + {/* Projected amounts */} +
+
+

Weekly amount

+

{formatCurrency(Number(data.weekly_amount), "GBP")}

+
+
+

Annual amount

+

{formatCurrency(Number(data.annual_amount), "GBP")}

+
+
+ +

+ Payable from age {data.state_pension_age}. + {data.checked_date && ` NI record checked ${data.checked_date}.`} + {" "}Based on 2025/26 rate of £221.80/week. +

+
+ ) : ( +
+

+ Enter your NI qualifying years from your{" "} + + gov.uk State Pension statement + {" "} + to see your projected State Pension. +

+ +
+ )} +
+ ); +} diff --git a/frontend/src/pages/tax/PensionTaxSection.tsx b/frontend/src/pages/tax/PensionTaxSection.tsx new file mode 100644 index 0000000..65408a9 --- /dev/null +++ b/frontend/src/pages/tax/PensionTaxSection.tsx @@ -0,0 +1,142 @@ +import { Link } from "react-router-dom"; +import { ExternalLink, ShieldCheck } from "lucide-react"; +import type { TaxReport, BandBreakdown } from "@/api/tax"; + +function gbp(v: string) { + return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); +} + +function Kv({ label, value, highlight }: { label: string; value: string; highlight?: "green" | "yellow" }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +interface Props { + report: TaxReport; +} + +export default function PensionTaxSection({ report }: Props) { + const p = report.pensions; + if (!p) return null; + + const allowanceUsed = Number(p.annual_allowance_used); + const allowanceTotal = Number(p.standard_allowance); + const usedPct = Math.min(100, (allowanceUsed / allowanceTotal) * 100); + const barColour = usedPct >= 90 ? "bg-red-500" : usedPct >= 70 ? "bg-amber-500" : "bg-primary"; + + const hasNetPay = Number(p.net_pay_total) > 0; + const hasSalSac = Number(p.salary_sacrifice_total) > 0; + const hasRas = Number(p.ras_gross_total) > 0; + const higherClaimable = Number(p.higher_rate_claimable); + const additionalClaimable = Number(p.additional_rate_claimable); + + // Determine if taxpayer is higher or additional rate from band breakdown + const inHigherBand = report.income_tax.band_breakdown.some( + (b: BandBreakdown) => b.rate >= 0.4 && b.taxable > 0, + ); + const inAdditionalBand = report.income_tax.band_breakdown.some( + (b: BandBreakdown) => b.rate >= 0.45 && b.taxable > 0, + ); + + const showReliefClaim = hasRas && (inHigherBand || inAdditionalBand); + const totalClaimable = inAdditionalBand + ? higherClaimable + additionalClaimable + : higherClaimable; + + return ( +
+ {/* Header */} +
+
+ +

Pension contributions

+
+ + View pensions + +
+ + {/* Annual allowance bar */} +
+
+ Annual allowance used + {gbp(p.annual_allowance_used)} of {gbp(p.standard_allowance)} +
+
+
+
+

+ {gbp(p.annual_allowance_remaining)} remaining — contributions above £60,000 are subject to the Annual Allowance Charge. +

+
+ + {/* Contribution breakdown */} +
+ {hasNetPay && ( + + )} + {hasSalSac && ( + + )} + {hasRas && ( + + )} +
+ + {/* Relief notes */} + {(hasNetPay || hasSalSac) && ( +

+ Net pay and salary sacrifice contributions are deducted by your employer before PAYE — tax relief is automatic and should already be reflected in your payslip gross figures. +

+ )} + + {/* Higher/additional rate relief to claim */} + {showReliefClaim && ( +
+

+ Higher-rate relief to claim via Self Assessment +

+

+ {gbp(String(totalClaimable))} +

+

+ Your relief-at-source pension provider claims 20% basic rate relief from HMRC automatically. + As a {inAdditionalBand ? "45%" : "40%"} taxpayer you can reclaim the additional{" "} + {inAdditionalBand ? "25%" : "20%"} on your gross contributions of {gbp(p.ras_gross_total)}{" "} + via your Self Assessment return. +

+
+ )} +
+ ); +} diff --git a/frontend/src/pages/tax/TaxPage.tsx b/frontend/src/pages/tax/TaxPage.tsx index 68b6599..8a016bf 100644 --- a/frontend/src/pages/tax/TaxPage.tsx +++ b/frontend/src/pages/tax/TaxPage.tsx @@ -11,6 +11,7 @@ import CGTSection from "./CGTSection"; import DividendSection from "./DividendSection"; import OverallLiabilityCard from "./OverallLiabilityCard"; import RateConfigModal from "./RateConfigModal"; +import PensionTaxSection from "./PensionTaxSection"; // --------------------------------------------------------------------------- // Loading skeleton @@ -220,6 +221,7 @@ export default function TaxPage() { +