From 1a2c8efd01aecf4a49c7f2e795d93492605a3fed Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 28 Apr 2026 09:59:01 +0000 Subject: [PATCH] Add pensions module and integrate with tax report Adds a full pensions feature: SIPP/workplace DC/LISA account metadata, contribution recording with relief-at-source/net-pay/salary-sacrifice gross calculations, state pension tracker, annual allowance monitor, and LISA summary. Pension contributions feed into the tax report (RAS gross totals, allowance used). Includes two Alembic migrations, backend service/schema/API, and full frontend pensions page with cards for allowance, state pension, LISA, and retirement projection. Also fixes CSRF cookie secure flag (must be false for HTTP deployments) and extends tax schemas/service to expose pension data in the report. Co-Authored-By: Claude Sonnet 4.6 --- backend/Dockerfile | 8 + .../versions/0007_add_pension_tables.py | 84 +++ .../versions/0008_add_state_pension_record.py | 41 ++ backend/app/api/router.py | 3 +- backend/app/api/v1/auth.py | 6 +- backend/app/api/v1/pension.py | 306 ++++++++ backend/app/db/models/__init__.py | 2 + backend/app/db/models/pension.py | 59 ++ backend/app/schemas/pension.py | 195 +++++ backend/app/schemas/tax.py | 12 + backend/app/services/pension_service.py | 664 ++++++++++++++++++ backend/app/services/tax_service.py | 50 +- docker-compose.yml | 26 + frontend/src/App.tsx | 2 + frontend/src/api/pensions.ts | 240 +++++++ frontend/src/api/tax.ts | 10 + frontend/src/components/layout/MobileNav.tsx | 3 +- frontend/src/components/layout/Sidebar.tsx | 2 + .../src/pages/accounts/AccountFormModal.tsx | 5 +- .../pages/pensions/AnnualAllowanceCard.tsx | 206 ++++++ .../pages/pensions/ContributionFormModal.tsx | 245 +++++++ .../pensions/ContributionHistoryTable.tsx | 154 ++++ frontend/src/pages/pensions/LisaInfoCard.tsx | 135 ++++ .../src/pages/pensions/PensionAccountCard.tsx | 156 ++++ .../pages/pensions/PensionMetadataModal.tsx | 219 ++++++ frontend/src/pages/pensions/PensionsPage.tsx | 189 +++++ .../pensions/RetirementProjectionCard.tsx | 200 ++++++ .../src/pages/pensions/StatePensionWidget.tsx | 179 +++++ frontend/src/pages/tax/PensionTaxSection.tsx | 142 ++++ frontend/src/pages/tax/TaxPage.tsx | 2 + 30 files changed, 3537 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/0007_add_pension_tables.py create mode 100644 backend/alembic/versions/0008_add_state_pension_record.py create mode 100644 backend/app/api/v1/pension.py create mode 100644 backend/app/db/models/pension.py create mode 100644 backend/app/schemas/pension.py create mode 100644 backend/app/services/pension_service.py create mode 100644 frontend/src/api/pensions.ts create mode 100644 frontend/src/pages/pensions/AnnualAllowanceCard.tsx create mode 100644 frontend/src/pages/pensions/ContributionFormModal.tsx create mode 100644 frontend/src/pages/pensions/ContributionHistoryTable.tsx create mode 100644 frontend/src/pages/pensions/LisaInfoCard.tsx create mode 100644 frontend/src/pages/pensions/PensionAccountCard.tsx create mode 100644 frontend/src/pages/pensions/PensionMetadataModal.tsx create mode 100644 frontend/src/pages/pensions/PensionsPage.tsx create mode 100644 frontend/src/pages/pensions/RetirementProjectionCard.tsx create mode 100644 frontend/src/pages/pensions/StatePensionWidget.tsx create mode 100644 frontend/src/pages/tax/PensionTaxSection.tsx 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() { +