Add pensions module and integrate with tax report

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

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

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

View file

@ -16,6 +16,14 @@ COPY pyproject.toml ./
FROM base AS deps FROM base AS deps
RUN uv pip install --system --no-cache -e . 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 FROM deps AS production
COPY app/ ./app/ COPY app/ ./app/
COPY alembic/ ./alembic/ COPY alembic/ ./alembic/

View file

@ -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")

View file

@ -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")

View file

@ -1,6 +1,6 @@
from fastapi import APIRouter 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 = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["auth"]) 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(settings.router)
router.include_router(subscriptions.router) router.include_router(subscriptions.router)
router.include_router(tax.router) router.include_router(tax.router)
router.include_router(pension.router)

View file

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

View file

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

View file

@ -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.net_worth_snapshot import NetWorthSnapshot
from app.db.models.audit_log import AuditLog from app.db.models.audit_log import AuditLog
from app.db.models.tax import TaxRateConfig, TaxProfile, Payslip, ManualCGTDisposal from app.db.models.tax import TaxRateConfig, TaxProfile, Payslip, ManualCGTDisposal
from app.db.models.pension import PensionMetadata, PensionContribution, StatePensionRecord
__all__ = [ __all__ = [
"User", "Session", "Account", "Category", "Transaction", "Budget", "User", "Session", "Account", "Category", "Transaction", "Budget",
"Asset", "AssetPrice", "InvestmentHolding", "InvestmentTransaction", "Asset", "AssetPrice", "InvestmentHolding", "InvestmentTransaction",
"Currency", "ExchangeRate", "NetWorthSnapshot", "AuditLog", "Currency", "ExchangeRate", "NetWorthSnapshot", "AuditLog",
"TaxRateConfig", "TaxProfile", "Payslip", "ManualCGTDisposal", "TaxRateConfig", "TaxProfile", "Payslip", "ManualCGTDisposal",
"PensionMetadata", "PensionContribution", "StatePensionRecord",
] ]

View file

@ -0,0 +1,59 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, LargeBinary, Numeric, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class PensionMetadata(Base):
__tablename__ = "pension_metadata"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
pension_type: Mapped[str] = mapped_column(String(20), nullable=False) # workplace_dc|workplace_db|sipp|lisa
provider_name_enc: Mapped[bytes | None] = mapped_column("provider_name", LargeBinary, nullable=True)
scheme_name_enc: Mapped[bytes | None] = mapped_column("scheme_name", LargeBinary, nullable=True)
member_reference_enc: Mapped[bytes | None] = mapped_column("member_reference", LargeBinary, nullable=True)
dob: Mapped[date | None] = mapped_column(Date, nullable=True)
target_retirement_age: Mapped[int | None] = mapped_column(Integer, nullable=True)
assumed_growth_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True) # e.g. 0.0500
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
account: Mapped["Account"] = relationship(lazy="noload") # type: ignore[name-defined]
contributions: Mapped[list["PensionContribution"]] = relationship(back_populates="pension", lazy="noload", cascade="all, delete-orphan")
class PensionContribution(Base):
__tablename__ = "pension_contributions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
pension_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pension_metadata.id", ondelete="CASCADE"), nullable=False, index=True)
contribution_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
tax_year: Mapped[int] = mapped_column(Integer, nullable=False, index=True) # year ending 5 Apr, e.g. 2026
member_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
employer_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=0)
relief_type: Mapped[str] = mapped_column(String(20), nullable=False) # relief_at_source|net_pay|salary_sacrifice|none
gross_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
relief_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=0)
notes_enc: Mapped[bytes | None] = mapped_column("notes", LargeBinary, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
pension: Mapped["PensionMetadata"] = relationship(back_populates="contributions", lazy="noload")
class StatePensionRecord(Base):
__tablename__ = "state_pension_records"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
qualifying_years: Mapped[int] = mapped_column(Integer, nullable=False)
checked_date: Mapped[date | None] = mapped_column(Date, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

View file

@ -0,0 +1,195 @@
import uuid
from datetime import date as DateType, datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field, model_validator
PensionType = Literal["workplace_dc", "workplace_db", "sipp", "lisa"]
ReliefType = Literal["relief_at_source", "net_pay", "salary_sacrifice", "none"]
class PensionMetadataCreate(BaseModel):
pension_type: PensionType
provider_name: str | None = None
scheme_name: str | None = None
member_reference: str | None = None
dob: DateType | None = None
target_retirement_age: int | None = Field(default=None, ge=55, le=90)
assumed_growth_rate: Decimal | None = Field(default=None, ge=0, le=1)
class PensionMetadataUpdate(BaseModel):
pension_type: PensionType | None = None
provider_name: str | None = None
scheme_name: str | None = None
member_reference: str | None = None
dob: DateType | None = None
target_retirement_age: int | None = Field(default=None, ge=55, le=90)
assumed_growth_rate: Decimal | None = Field(default=None, ge=0, le=1)
class PensionMetadataResponse(BaseModel):
id: uuid.UUID
account_id: uuid.UUID
pension_type: PensionType
provider_name: str | None
scheme_name: str | None
member_reference: str | None
dob: DateType | None
target_retirement_age: int | None
assumed_growth_rate: Decimal | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class PensionContributionCreate(BaseModel):
contribution_date: DateType
member_amount: Decimal = Field(..., ge=0)
employer_amount: Decimal = Field(default=Decimal("0"), ge=0)
relief_type: ReliefType
notes: str | None = None
@model_validator(mode="after")
def check_amounts(self) -> "PensionContributionCreate":
if self.member_amount == 0 and self.employer_amount == 0:
raise ValueError("At least one of member_amount or employer_amount must be greater than 0")
return self
class PensionContributionUpdate(BaseModel):
contribution_date: DateType | None = None
member_amount: Decimal | None = Field(default=None, ge=0)
employer_amount: Decimal | None = Field(default=None, ge=0)
relief_type: ReliefType | None = None
notes: str | None = None
class PensionContributionResponse(BaseModel):
id: uuid.UUID
pension_id: uuid.UUID
contribution_date: DateType
tax_year: int
member_amount: Decimal
employer_amount: Decimal
relief_type: ReliefType
gross_amount: Decimal
relief_amount: Decimal
notes: str | None
created_at: datetime
model_config = {"from_attributes": True}
class YtdSummary(BaseModel):
tax_year: int
member_total: Decimal
employer_total: Decimal
gross_total: Decimal
relief_total: Decimal
contribution_count: int
class PensionAccountResponse(BaseModel):
"""Account row joined with pension metadata and YTD summary."""
account_id: uuid.UUID
account_name: str
current_balance: Decimal
currency: str
color: str
metadata: PensionMetadataResponse | None
ytd: YtdSummary | None
_FULL_SP_WEEKLY = Decimal("221.80")
_SP_AGE = 67
class StatePensionCreate(BaseModel):
qualifying_years: int = Field(..., ge=0, le=50)
checked_date: DateType | None = None
class StatePensionResponse(BaseModel):
id: uuid.UUID
qualifying_years: int
checked_date: DateType | None
weekly_amount: Decimal
annual_amount: Decimal
is_full_pension: bool
years_to_full: int
state_pension_age: int
model_config = {"from_attributes": True}
class ProjectionScenario(BaseModel):
label: str
growth_rate: Decimal
projected_pot: Decimal
annual_drawdown_4pct: Decimal
annual_drawdown_3pct: Decimal
class ChartDataPoint(BaseModel):
year: int
pot_2pct: Decimal
pot_5pct: Decimal
pot_8pct: Decimal
class RetirementProjection(BaseModel):
account_id: uuid.UUID
account_name: str
current_balance: Decimal
years_to_retirement: int
target_retirement_age: int
scenarios: list[ProjectionScenario]
state_pension_annual: Decimal | None
state_pension_age: int
chart_data: list[ChartDataPoint]
class LisaTaxYearBreakdown(BaseModel):
tax_year: int
contributions: Decimal
bonus_expected: Decimal
limit_remaining: Decimal
limit_used_pct: Decimal
class LisaSummary(BaseModel):
account_id: uuid.UUID
account_name: str
tax_year_breakdown: list[LisaTaxYearBreakdown]
current_year_contributions: Decimal
current_year_bonus_expected: Decimal
current_year_limit_remaining: Decimal
total_contributions: Decimal
total_bonus_expected: Decimal
account_opened_date: datetime
withdrawal_penalty_amount: Decimal
withdrawal_penalty_pct: Decimal
penalty_warning: bool
class CarryForwardYear(BaseModel):
tax_year: int
standard_allowance: Decimal
contributions: Decimal
unused: Decimal
class AllowanceSummary(BaseModel):
tax_year: int
standard_allowance: Decimal
contributions_total: Decimal
remaining: Decimal
carry_forward: list[CarryForwardYear]
carry_forward_total: Decimal
total_available: Decimal
relief_ras_total: Decimal
relief_higher_rate_claimable: Decimal
relief_additional_rate_claimable: Decimal

View file

@ -203,6 +203,17 @@ class IncomeSummary(BaseModel):
payslips: list[dict[str, Any]] 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): class TaxReportResponse(BaseModel):
tax_year: int tax_year: int
tax_year_display: str tax_year_display: str
@ -212,4 +223,5 @@ class TaxReportResponse(BaseModel):
ni: NISummary ni: NISummary
cgt: CGTSummary cgt: CGTSummary
dividends: DividendSummary dividends: DividendSummary
pensions: PensionTaxSummary | None
summary: TaxReportSummary summary: TaxReportSummary

View file

@ -0,0 +1,664 @@
from __future__ import annotations
import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import TYPE_CHECKING
from app.db.models.account import Account
from sqlalchemy import func, select
from app.core.security import decrypt_field, encrypt_field
from app.db.models.pension import PensionContribution, PensionMetadata, StatePensionRecord
from app.schemas.pension import (
AllowanceSummary,
CarryForwardYear,
ChartDataPoint,
LisaSummary,
LisaTaxYearBreakdown,
PensionContributionCreate,
PensionContributionUpdate,
PensionMetadataCreate,
PensionMetadataUpdate,
ProjectionScenario,
RetirementProjection,
StatePensionCreate,
YtdSummary,
_FULL_SP_WEEKLY,
_SP_AGE,
)
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
def _uk_tax_year(d: date) -> int:
"""Return the tax year ending on 5 Apr that contains date d.
E.g. 2026-01-01 2026; 2025-04-05 2025; 2025-04-06 2026."""
return d.year + 1 if (d.month > 4 or (d.month == 4 and d.day >= 6)) else d.year
def _compute_gross_and_relief(
member_amount: Decimal,
relief_type: str,
) -> tuple[Decimal, Decimal]:
"""Return (gross_amount, relief_amount) given a net member contribution and relief type."""
if relief_type == "relief_at_source":
gross = member_amount / Decimal("0.8")
relief = gross - member_amount
elif relief_type in ("net_pay", "salary_sacrifice"):
gross = member_amount
relief = Decimal("0")
else: # none
gross = member_amount
relief = Decimal("0")
return gross.quantize(Decimal("0.01")), relief.quantize(Decimal("0.01"))
def _enc(v: str | None) -> bytes | None:
return encrypt_field(v) if v else None
def _dec(v: bytes | None) -> str | None:
return decrypt_field(v) if v else None
# ---------------------------------------------------------------------------
# Pension metadata (one per pension account)
# ---------------------------------------------------------------------------
async def get_pension_metadata(
db: "AsyncSession",
account_id: uuid.UUID,
user_id: uuid.UUID,
) -> PensionMetadata | None:
result = await db.execute(
select(PensionMetadata).where(
PensionMetadata.account_id == account_id,
PensionMetadata.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def upsert_pension_metadata(
db: "AsyncSession",
account_id: uuid.UUID,
user_id: uuid.UUID,
data: PensionMetadataCreate,
) -> PensionMetadata:
existing = await get_pension_metadata(db, account_id, user_id)
now = datetime.now(timezone.utc)
if existing:
existing.pension_type = data.pension_type
existing.provider_name_enc = _enc(data.provider_name)
existing.scheme_name_enc = _enc(data.scheme_name)
existing.member_reference_enc = _enc(data.member_reference)
existing.dob = data.dob
existing.target_retirement_age = data.target_retirement_age
existing.assumed_growth_rate = data.assumed_growth_rate
existing.updated_at = now
return existing
meta = PensionMetadata(
id=uuid.uuid4(),
user_id=user_id,
account_id=account_id,
pension_type=data.pension_type,
provider_name_enc=_enc(data.provider_name),
scheme_name_enc=_enc(data.scheme_name),
member_reference_enc=_enc(data.member_reference),
dob=data.dob,
target_retirement_age=data.target_retirement_age,
assumed_growth_rate=data.assumed_growth_rate,
created_at=now,
updated_at=now,
)
db.add(meta)
return meta
async def update_pension_metadata(
db: "AsyncSession",
account_id: uuid.UUID,
user_id: uuid.UUID,
data: PensionMetadataUpdate,
) -> PensionMetadata | None:
meta = await get_pension_metadata(db, account_id, user_id)
if not meta:
return None
now = datetime.now(timezone.utc)
if data.pension_type is not None:
meta.pension_type = data.pension_type
if data.provider_name is not None:
meta.provider_name_enc = _enc(data.provider_name)
if data.scheme_name is not None:
meta.scheme_name_enc = _enc(data.scheme_name)
if data.member_reference is not None:
meta.member_reference_enc = _enc(data.member_reference)
if data.dob is not None:
meta.dob = data.dob
if data.target_retirement_age is not None:
meta.target_retirement_age = data.target_retirement_age
if data.assumed_growth_rate is not None:
meta.assumed_growth_rate = data.assumed_growth_rate
meta.updated_at = now
return meta
def decode_metadata(meta: PensionMetadata) -> dict:
return {
"id": meta.id,
"account_id": meta.account_id,
"pension_type": meta.pension_type,
"provider_name": _dec(meta.provider_name_enc),
"scheme_name": _dec(meta.scheme_name_enc),
"member_reference": _dec(meta.member_reference_enc),
"dob": meta.dob,
"target_retirement_age": meta.target_retirement_age,
"assumed_growth_rate": meta.assumed_growth_rate,
"created_at": meta.created_at,
"updated_at": meta.updated_at,
}
# ---------------------------------------------------------------------------
# Contributions
# ---------------------------------------------------------------------------
async def list_contributions(
db: "AsyncSession",
pension_id: uuid.UUID,
user_id: uuid.UUID,
tax_year: int | None = None,
) -> list[dict]:
q = select(PensionContribution).where(
PensionContribution.pension_id == pension_id,
PensionContribution.user_id == user_id,
)
if tax_year is not None:
q = q.where(PensionContribution.tax_year == tax_year)
q = q.order_by(PensionContribution.contribution_date.desc())
result = await db.execute(q)
rows = result.scalars().all()
return [_decode_contribution(r) for r in rows]
async def add_contribution(
db: "AsyncSession",
pension_id: uuid.UUID,
user_id: uuid.UUID,
data: PensionContributionCreate,
) -> dict:
gross, relief = _compute_gross_and_relief(data.member_amount, data.relief_type)
tax_year = _uk_tax_year(data.contribution_date)
contrib = PensionContribution(
id=uuid.uuid4(),
user_id=user_id,
pension_id=pension_id,
contribution_date=data.contribution_date,
tax_year=tax_year,
member_amount=data.member_amount,
employer_amount=data.employer_amount,
relief_type=data.relief_type,
gross_amount=gross,
relief_amount=relief,
notes_enc=_enc(data.notes),
created_at=datetime.now(timezone.utc),
)
db.add(contrib)
return _decode_contribution(contrib)
async def update_contribution(
db: "AsyncSession",
contribution_id: uuid.UUID,
user_id: uuid.UUID,
data: PensionContributionUpdate,
) -> dict | None:
result = await db.execute(
select(PensionContribution).where(
PensionContribution.id == contribution_id,
PensionContribution.user_id == user_id,
)
)
contrib = result.scalar_one_or_none()
if not contrib:
return None
if data.contribution_date is not None:
contrib.contribution_date = data.contribution_date
contrib.tax_year = _uk_tax_year(data.contribution_date)
if data.member_amount is not None:
contrib.member_amount = data.member_amount
if data.employer_amount is not None:
contrib.employer_amount = data.employer_amount
if data.relief_type is not None:
contrib.relief_type = data.relief_type
if data.notes is not None:
contrib.notes_enc = _enc(data.notes)
gross, relief = _compute_gross_and_relief(contrib.member_amount, contrib.relief_type)
contrib.gross_amount = gross
contrib.relief_amount = relief
return _decode_contribution(contrib)
async def delete_contribution(
db: "AsyncSession",
contribution_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
result = await db.execute(
select(PensionContribution).where(
PensionContribution.id == contribution_id,
PensionContribution.user_id == user_id,
)
)
contrib = result.scalar_one_or_none()
if not contrib:
return False
await db.delete(contrib)
return True
def _decode_contribution(c: PensionContribution) -> dict:
return {
"id": c.id,
"pension_id": c.pension_id,
"contribution_date": c.contribution_date,
"tax_year": c.tax_year,
"member_amount": c.member_amount,
"employer_amount": c.employer_amount,
"relief_type": c.relief_type,
"gross_amount": c.gross_amount,
"relief_amount": c.relief_amount,
"notes": _dec(c.notes_enc),
"created_at": c.created_at,
}
# ---------------------------------------------------------------------------
# YTD / summary
# ---------------------------------------------------------------------------
async def get_ytd_summary(
db: "AsyncSession",
pension_id: uuid.UUID,
user_id: uuid.UUID,
tax_year: int,
) -> YtdSummary:
result = await db.execute(
select(
func.count(PensionContribution.id).label("count"),
func.coalesce(func.sum(PensionContribution.member_amount), 0).label("member_total"),
func.coalesce(func.sum(PensionContribution.employer_amount), 0).label("employer_total"),
func.coalesce(func.sum(PensionContribution.gross_amount), 0).label("gross_total"),
func.coalesce(func.sum(PensionContribution.relief_amount), 0).label("relief_total"),
).where(
PensionContribution.pension_id == pension_id,
PensionContribution.user_id == user_id,
PensionContribution.tax_year == tax_year,
)
)
row = result.one()
return YtdSummary(
tax_year=tax_year,
member_total=Decimal(str(row.member_total)),
employer_total=Decimal(str(row.employer_total)),
gross_total=Decimal(str(row.gross_total)),
relief_total=Decimal(str(row.relief_total)),
contribution_count=row.count,
)
async def get_all_pensions_ytd_summary(
db: "AsyncSession",
user_id: uuid.UUID,
tax_year: int,
) -> YtdSummary:
"""Aggregate YTD totals across all pension accounts for the summary header."""
result = await db.execute(
select(
func.count(PensionContribution.id).label("count"),
func.coalesce(func.sum(PensionContribution.member_amount), 0).label("member_total"),
func.coalesce(func.sum(PensionContribution.employer_amount), 0).label("employer_total"),
func.coalesce(func.sum(PensionContribution.gross_amount), 0).label("gross_total"),
func.coalesce(func.sum(PensionContribution.relief_amount), 0).label("relief_total"),
).where(
PensionContribution.user_id == user_id,
PensionContribution.tax_year == tax_year,
)
)
row = result.one()
return YtdSummary(
tax_year=tax_year,
member_total=Decimal(str(row.member_total)),
employer_total=Decimal(str(row.employer_total)),
gross_total=Decimal(str(row.gross_total)),
relief_total=Decimal(str(row.relief_total)),
contribution_count=row.count,
)
# ---------------------------------------------------------------------------
# Annual allowance
# ---------------------------------------------------------------------------
_STANDARD_ALLOWANCE = Decimal("60000")
async def get_allowance_summary(
db: "AsyncSession",
user_id: uuid.UUID,
tax_year: int,
) -> AllowanceSummary:
"""
Returns annual allowance usage for tax_year plus carry-forward from
the prior 3 tax years (oldest-first, as HMRC requires).
"""
years_to_fetch = [tax_year - 3, tax_year - 2, tax_year - 1, tax_year]
result = await db.execute(
select(
PensionContribution.tax_year,
PensionContribution.relief_type,
func.coalesce(func.sum(PensionContribution.gross_amount), 0).label("gross_total"),
func.coalesce(func.sum(PensionContribution.relief_amount), 0).label("relief_total"),
).where(
PensionContribution.user_id == user_id,
PensionContribution.tax_year.in_(years_to_fetch),
).group_by(
PensionContribution.tax_year,
PensionContribution.relief_type,
)
)
rows = result.all()
year_gross: dict[int, Decimal] = {y: Decimal("0") for y in years_to_fetch}
year_ras_relief: dict[int, Decimal] = {y: Decimal("0") for y in years_to_fetch}
for row in rows:
year_gross[row.tax_year] = year_gross.get(row.tax_year, Decimal("0")) + Decimal(str(row.gross_total))
if row.relief_type == "relief_at_source":
year_ras_relief[row.tax_year] = year_ras_relief.get(row.tax_year, Decimal("0")) + Decimal(str(row.relief_total))
carry_forward: list[CarryForwardYear] = []
carry_forward_total = Decimal("0")
for yr in [tax_year - 3, tax_year - 2, tax_year - 1]:
contribs = year_gross[yr]
unused = max(Decimal("0"), _STANDARD_ALLOWANCE - contribs)
carry_forward.append(CarryForwardYear(
tax_year=yr,
standard_allowance=_STANDARD_ALLOWANCE,
contributions=contribs,
unused=unused,
))
carry_forward_total += unused
contributions_total = year_gross[tax_year]
remaining = max(Decimal("0"), _STANDARD_ALLOWANCE - contributions_total)
total_available = _STANDARD_ALLOWANCE + carry_forward_total
ras_relief = year_ras_relief[tax_year]
ras_gross_current = sum(
(Decimal(str(r.gross_total)) for r in rows if r.tax_year == tax_year and r.relief_type == "relief_at_source"),
Decimal("0"),
)
higher_rate_claimable = (ras_gross_current * Decimal("0.20")).quantize(Decimal("0.01"))
additional_rate_claimable = (ras_gross_current * Decimal("0.05")).quantize(Decimal("0.01"))
return AllowanceSummary(
tax_year=tax_year,
standard_allowance=_STANDARD_ALLOWANCE,
contributions_total=contributions_total,
remaining=remaining,
carry_forward=carry_forward,
carry_forward_total=carry_forward_total,
total_available=total_available,
relief_ras_total=ras_relief,
relief_higher_rate_claimable=higher_rate_claimable,
relief_additional_rate_claimable=additional_rate_claimable,
)
# ---------------------------------------------------------------------------
# State Pension
# ---------------------------------------------------------------------------
def _sp_amounts(qualifying_years: int) -> tuple[Decimal, Decimal]:
weekly = min(_FULL_SP_WEEKLY, (_FULL_SP_WEEKLY * qualifying_years / 35).quantize(Decimal("0.01")))
return weekly, (weekly * 52).quantize(Decimal("0.01"))
async def get_state_pension(
db: "AsyncSession",
user_id: uuid.UUID,
) -> StatePensionRecord | None:
result = await db.execute(
select(StatePensionRecord).where(StatePensionRecord.user_id == user_id)
)
return result.scalar_one_or_none()
async def upsert_state_pension(
db: "AsyncSession",
user_id: uuid.UUID,
data: StatePensionCreate,
) -> dict:
now = datetime.now(timezone.utc)
existing = await get_state_pension(db, user_id)
if existing:
existing.qualifying_years = data.qualifying_years
existing.checked_date = data.checked_date
existing.updated_at = now
record = existing
else:
record = StatePensionRecord(
id=uuid.uuid4(),
user_id=user_id,
qualifying_years=data.qualifying_years,
checked_date=data.checked_date,
created_at=now,
updated_at=now,
)
db.add(record)
return _decode_sp(record)
def _decode_sp(record: StatePensionRecord) -> dict:
weekly, annual = _sp_amounts(record.qualifying_years)
years_to_full = max(0, 35 - record.qualifying_years)
return {
"id": record.id,
"qualifying_years": record.qualifying_years,
"checked_date": record.checked_date,
"weekly_amount": weekly,
"annual_amount": annual,
"is_full_pension": record.qualifying_years >= 35,
"years_to_full": years_to_full,
"state_pension_age": _SP_AGE,
}
# ---------------------------------------------------------------------------
# Retirement projection
# ---------------------------------------------------------------------------
_SCENARIOS = [
("Conservative 2%", Decimal("0.02")),
("Moderate 5%", Decimal("0.05")),
("Growth 8%", Decimal("0.08")),
]
async def get_retirement_projection(
db: "AsyncSession",
user_id: uuid.UUID,
account_id: uuid.UUID,
) -> RetirementProjection:
acc_result = await db.execute(
select(Account).where(Account.id == account_id, Account.user_id == user_id, Account.deleted_at.is_(None))
)
account = acc_result.scalar_one_or_none()
if not account:
raise ValueError("Account not found")
meta = await get_pension_metadata(db, account_id, user_id)
if not meta or not meta.dob or not meta.target_retirement_age:
raise ValueError("Set date of birth and target retirement age in pension details first")
today = date.today()
age_today = (today - meta.dob).days / 365.25
years_to_retirement = max(0, int(meta.target_retirement_age - age_today))
current_balance = Decimal(str(account.current_balance))
# Build year-by-year chart data
chart_data: list[ChartDataPoint] = []
pots = {r: current_balance for _, r in _SCENARIOS}
current_year = today.year
for yr in range(years_to_retirement + 1):
chart_data.append(ChartDataPoint(
year=current_year + yr,
pot_2pct=pots[Decimal("0.02")].quantize(Decimal("1")),
pot_5pct=pots[Decimal("0.05")].quantize(Decimal("1")),
pot_8pct=pots[Decimal("0.08")].quantize(Decimal("1")),
))
for _, rate in _SCENARIOS:
pots[rate] = pots[rate] * (1 + rate)
# Final pot values at retirement (last chart point)
final = chart_data[-1] if chart_data else None
scenario_pots = {
Decimal("0.02"): Decimal(str(final.pot_2pct)) if final else current_balance,
Decimal("0.05"): Decimal(str(final.pot_5pct)) if final else current_balance,
Decimal("0.08"): Decimal(str(final.pot_8pct)) if final else current_balance,
}
scenarios = [
ProjectionScenario(
label=label,
growth_rate=rate,
projected_pot=scenario_pots[rate],
annual_drawdown_4pct=(scenario_pots[rate] * Decimal("0.04")).quantize(Decimal("1")),
annual_drawdown_3pct=(scenario_pots[rate] * Decimal("0.03")).quantize(Decimal("1")),
)
for label, rate in _SCENARIOS
]
# State pension
sp_record = await get_state_pension(db, user_id)
sp_annual = None
if sp_record:
_, sp_annual = _sp_amounts(sp_record.qualifying_years)
from app.core.security import decrypt_field
acc_name = decrypt_field(account.name_enc) if account.name_enc else ""
return RetirementProjection(
account_id=account_id,
account_name=acc_name,
current_balance=current_balance,
years_to_retirement=years_to_retirement,
target_retirement_age=meta.target_retirement_age,
scenarios=scenarios,
state_pension_annual=sp_annual,
state_pension_age=_SP_AGE,
chart_data=chart_data,
)
# ---------------------------------------------------------------------------
# LISA summary
# ---------------------------------------------------------------------------
_LISA_ANNUAL_LIMIT = Decimal("4000")
_LISA_BONUS_RATE = Decimal("0.25")
_LISA_WITHDRAWAL_PENALTY = Decimal("0.25")
async def get_lisa_summary(
db: "AsyncSession",
user_id: uuid.UUID,
account_id: uuid.UUID,
) -> LisaSummary:
# Verify account is LISA type
meta_result = await db.execute(
select(PensionMetadata).where(
PensionMetadata.account_id == account_id,
PensionMetadata.user_id == user_id,
)
)
meta = meta_result.scalar_one_or_none()
if meta is None or meta.pension_type != "lisa":
raise ValueError("Account is not a LISA")
# Load account for balance and created_at
acc_result = await db.execute(
select(Account).where(Account.id == account_id, Account.user_id == user_id)
)
account = acc_result.scalar_one_or_none()
if account is None:
raise ValueError("Account not found")
current_balance = Decimal(str(account.current_balance or 0))
# Load all contributions
contrib_result = await db.execute(
select(PensionContribution).where(
PensionContribution.pension_id == meta.id,
PensionContribution.user_id == user_id,
)
)
contribs = contrib_result.scalars().all()
# Group by tax year
by_year: dict[int, Decimal] = {}
for c in contribs:
by_year[c.tax_year] = by_year.get(c.tax_year, Decimal("0")) + Decimal(str(c.member_amount))
current_year = _uk_tax_year(date.today())
breakdown: list[LisaTaxYearBreakdown] = []
for ty in sorted(by_year):
contributions = by_year[ty].quantize(Decimal("0.01"))
capped = min(contributions, _LISA_ANNUAL_LIMIT)
bonus = (capped * _LISA_BONUS_RATE).quantize(Decimal("0.01"))
limit_remaining = max(Decimal("0"), _LISA_ANNUAL_LIMIT - contributions).quantize(Decimal("0.01"))
limit_used_pct = min(Decimal("100"), (contributions / _LISA_ANNUAL_LIMIT * 100)).quantize(Decimal("0.01"))
breakdown.append(LisaTaxYearBreakdown(
tax_year=ty,
contributions=contributions,
bonus_expected=bonus,
limit_remaining=limit_remaining,
limit_used_pct=limit_used_pct,
))
current_contributions = by_year.get(current_year, Decimal("0")).quantize(Decimal("0.01"))
current_capped = min(current_contributions, _LISA_ANNUAL_LIMIT)
current_bonus = (current_capped * _LISA_BONUS_RATE).quantize(Decimal("0.01"))
current_limit_remaining = max(Decimal("0"), _LISA_ANNUAL_LIMIT - current_contributions).quantize(Decimal("0.01"))
total_contributions = sum(by_year.values(), Decimal("0")).quantize(Decimal("0.01"))
total_capped = sum(min(v, _LISA_ANNUAL_LIMIT) for v in by_year.values())
total_bonus = (total_capped * _LISA_BONUS_RATE).quantize(Decimal("0.01"))
# Withdrawal penalty: 25% of current balance (which includes any bonus already paid)
penalty_amount = (current_balance * _LISA_WITHDRAWAL_PENALTY).quantize(Decimal("0.01"))
penalty_pct = _LISA_WITHDRAWAL_PENALTY * 100
return LisaSummary(
account_id=account_id,
account_name=decrypt_field(account.name_enc) if account.name_enc else "",
tax_year_breakdown=breakdown,
current_year_contributions=current_contributions,
current_year_bonus_expected=current_bonus,
current_year_limit_remaining=current_limit_remaining,
total_contributions=total_contributions,
total_bonus_expected=total_bonus,
account_opened_date=account.created_at,
withdrawal_penalty_amount=penalty_amount,
withdrawal_penalty_pct=penalty_pct,
penalty_warning=True,
)

View file

@ -11,7 +11,7 @@ from datetime import date, datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from sqlalchemy import delete, select from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decrypt_field, encrypt_field 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_transaction import InvestmentTransaction
from app.db.models.investment_holding import InvestmentHolding from app.db.models.investment_holding import InvestmentHolding
from app.db.models.asset import Asset from app.db.models.asset import Asset
from app.db.models.pension import PensionContribution, PensionMetadata
rates = await load_rates(db, user_id, tax_year) rates = await load_rates(db, user_id, tax_year)
start_date, end_date = tax_year_date_range(tax_year) start_date, end_date = tax_year_date_range(tax_year)
@ -676,6 +677,43 @@ async def build_tax_report(
"amount": str(amount), "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 ---- # ---- Calculations ----
income_tax_result = calculate_income_tax(gross_income, tax_code, rates) income_tax_result = calculate_income_tax(gross_income, tax_code, rates)
ni_result = calculate_ni(gross_income, rates) ni_result = calculate_ni(gross_income, rates)
@ -735,6 +773,16 @@ async def build_tax_report(
for k, v in dividend_result.items()}, for k, v in dividend_result.items()},
"dividend_transactions": dividend_rows, "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": { "summary": {
"total_liability": str(total_liability), "total_liability": str(total_liability),
"total_withheld": str(total_withheld), "total_withheld": str(total_withheld),

View file

@ -93,6 +93,32 @@ services:
timeout: 5s timeout: 5s
retries: 3 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: volumes:
redis_data: redis_data:

View file

@ -18,6 +18,7 @@ import PredictionsPage from "@/pages/predictions/PredictionsPage";
import SettingsPage from "@/pages/settings/SettingsPage"; import SettingsPage from "@/pages/settings/SettingsPage";
import SubscriptionsPage from "@/pages/subscriptions/SubscriptionsPage"; import SubscriptionsPage from "@/pages/subscriptions/SubscriptionsPage";
import TaxPage from "@/pages/tax/TaxPage"; import TaxPage from "@/pages/tax/TaxPage";
import PensionsPage from "@/pages/pensions/PensionsPage";
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token); const token = useAuthStore((s) => s.token);
@ -56,6 +57,7 @@ export default function App() {
<Route path="/investments" element={<PortfolioPage />} /> <Route path="/investments" element={<PortfolioPage />} />
<Route path="/investments/:assetId" element={<AssetDetail />} /> <Route path="/investments/:assetId" element={<AssetDetail />} />
<Route path="/tax" element={<TaxPage />} /> <Route path="/tax" element={<TaxPage />} />
<Route path="/pensions" element={<PensionsPage />} />
<Route path="/predictions" element={<PredictionsPage />} /> <Route path="/predictions" element={<PredictionsPage />} />
<Route path="/subscriptions" element={<SubscriptionsPage />} /> <Route path="/subscriptions" element={<SubscriptionsPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />

View file

@ -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<PensionMetadataCreate> {}
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<StatePensionResponse> {
const r = await api.get("/pensions/state-pension");
return r.data;
}
export async function upsertStatePension(data: StatePensionCreate): Promise<StatePensionResponse> {
const r = await api.post("/pensions/state-pension", data);
return r.data;
}
export async function getRetirementProjection(accountId: string): Promise<RetirementProjection> {
const r = await api.get(`/pensions/${accountId}/projection`);
return r.data;
}
export async function getPensionsAllowance(taxYear?: number): Promise<AllowanceSummary> {
const r = await api.get("/pensions/allowance", { params: taxYear ? { tax_year: taxYear } : {} });
return r.data;
}
export async function listPensionAccounts(): Promise<PensionAccount[]> {
const r = await api.get("/pensions");
return r.data;
}
export async function getPensionsSummary(taxYear?: number): Promise<YtdSummary> {
const r = await api.get("/pensions/summary", { params: taxYear ? { tax_year: taxYear } : {} });
return r.data;
}
export async function getPensionMetadata(accountId: string): Promise<PensionMetadata> {
const r = await api.get(`/pensions/${accountId}/metadata`);
return r.data;
}
export async function createPensionMetadata(accountId: string, data: PensionMetadataCreate): Promise<PensionMetadata> {
const r = await api.post(`/pensions/${accountId}/metadata`, data);
return r.data;
}
export async function updatePensionMetadata(accountId: string, data: PensionMetadataUpdate): Promise<PensionMetadata> {
const r = await api.put(`/pensions/${accountId}/metadata`, data);
return r.data;
}
export async function listContributions(accountId: string, taxYear?: number): Promise<PensionContribution[]> {
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<PensionContribution> {
const r = await api.post(`/pensions/${accountId}/contributions`, data);
return r.data;
}
export async function updateContribution(
accountId: string,
contributionId: string,
data: PensionContributionUpdate,
): Promise<PensionContribution> {
const r = await api.put(`/pensions/${accountId}/contributions/${contributionId}`, data);
return r.data;
}
export async function deleteContribution(accountId: string, contributionId: string): Promise<void> {
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<LisaSummary> {
const r = await api.get(`/pensions/${accountId}/lisa-summary`);
return r.data;
}

View file

@ -190,6 +190,16 @@ export interface TaxReport {
band_breakdown: BandBreakdown[]; band_breakdown: BandBreakdown[];
dividend_transactions: DividendTransactionItem[]; 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: { summary: {
total_liability: string; total_liability: string;
total_withheld: string; total_withheld: string;

View file

@ -2,7 +2,7 @@ import { Link, useLocation } from "react-router-dom";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { import {
LayoutDashboard, CreditCard, ArrowLeftRight, LayoutDashboard, CreditCard, ArrowLeftRight,
PiggyBank, TrendingUp, BarChart3, Sparkles, Settings, Repeat, Receipt, PiggyBank, TrendingUp, BarChart3, Sparkles, Settings, Repeat, Receipt, ShieldCheck,
} from "lucide-react"; } from "lucide-react";
const NAV = [ const NAV = [
@ -14,6 +14,7 @@ const NAV = [
{ href: "/investments", icon: TrendingUp, label: "Invest" }, { href: "/investments", icon: TrendingUp, label: "Invest" },
{ href: "/reports", icon: BarChart3, label: "Reports" }, { href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/tax", icon: Receipt, label: "Tax" }, { href: "/tax", icon: Receipt, label: "Tax" },
{ href: "/pensions", icon: ShieldCheck, label: "Pensions" },
{ href: "/predictions", icon: Sparkles, label: "Predict" }, { href: "/predictions", icon: Sparkles, label: "Predict" },
{ href: "/settings", icon: Settings, label: "Settings" }, { href: "/settings", icon: Settings, label: "Settings" },
]; ];

View file

@ -15,6 +15,7 @@ import {
Coins, Coins,
Repeat, Repeat,
Receipt, Receipt,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
@ -26,6 +27,7 @@ const navItems = [
{ href: "/investments", icon: TrendingUp, label: "Investments" }, { href: "/investments", icon: TrendingUp, label: "Investments" },
{ href: "/reports", icon: BarChart3, label: "Reports" }, { href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/tax", icon: Receipt, label: "Tax" }, { href: "/tax", icon: Receipt, label: "Tax" },
{ href: "/pensions", icon: ShieldCheck, label: "Pensions" },
{ href: "/predictions", icon: Sparkles, label: "Predictions" }, { href: "/predictions", icon: Sparkles, label: "Predictions" },
{ href: "/settings", icon: Settings, label: "Settings" }, { href: "/settings", icon: Settings, label: "Settings" },
]; ];

View file

@ -21,18 +21,19 @@ const COLORS = ["#6366f1", "#22c55e", "#0ea5e9", "#f59e0b", "#ec4899", "#ef4444"
interface Props { interface Props {
account?: Account; account?: Account;
defaultType?: string;
onClose: () => void; onClose: () => void;
onSubmit: (data: AccountCreate) => void; onSubmit: (data: AccountCreate) => void;
isLoading: boolean; 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 isEdit = !!account;
const [form, setForm] = useState({ const [form, setForm] = useState({
name: account?.name ?? "", name: account?.name ?? "",
institution: account?.institution ?? "", institution: account?.institution ?? "",
type: account?.type ?? "checking", type: account?.type ?? defaultType ?? "checking",
currency: account?.currency ?? "GBP", currency: account?.currency ?? "GBP",
opening_balance: account ? String(account.current_balance) : "0", opening_balance: account ? String(account.current_balance) : "0",
credit_limit: account?.credit_limit != null ? String(account.credit_limit) : "", credit_limit: account?.credit_limit != null ? String(account.credit_limit) : "",

View file

@ -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 (
<div className="rounded-lg border border-border bg-card p-5 space-y-5">
{/* Header + year selector */}
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground">Annual Allowance</h2>
<div className="flex items-center gap-1">
<button
onClick={() => setTaxYear((y) => y - 1)}
className="rounded p-1 text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium text-foreground w-16 text-center">
{taxYearDisplay(taxYear)}
</span>
<button
onClick={() => setTaxYear((y) => y + 1)}
disabled={taxYear >= maxYear}
className="rounded p-1 text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-30"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
{isLoading ? (
<div className="space-y-3 animate-pulse">
<div className="h-3 rounded bg-secondary/60 w-full" />
<div className="grid grid-cols-3 gap-3">
{[0, 1, 2].map((i) => <div key={i} className="h-12 rounded bg-secondary/60" />)}
</div>
</div>
) : data ? (
<>
{/* Warning / exceeded banners */}
{isExceeded && (
<div className="rounded-md bg-red-500/10 border border-red-500/30 px-3 py-2 text-sm text-red-500">
Annual allowance exceeded tax charge may apply on contributions above £{Number(data.standard_allowance).toLocaleString()}.
</div>
)}
{!isExceeded && isDanger && (
<div className="rounded-md bg-amber-500/10 border border-amber-500/30 px-3 py-2 text-sm text-amber-500">
Approaching annual allowance limit {formatCurrency(Number(data.remaining), "GBP")} remaining.
</div>
)}
{/* Progress bar */}
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{formatCurrency(Number(data.contributions_total), "GBP")} used</span>
<span>£{Number(data.standard_allowance).toLocaleString()} limit</span>
</div>
<div className="h-2.5 rounded-full bg-secondary overflow-hidden">
<div
className={`h-full rounded-full transition-all ${barColour}`}
style={{ width: `${usedPct}%` }}
/>
</div>
</div>
{/* Three stat pills */}
<div className="grid grid-cols-3 gap-3">
<div className="rounded-md bg-secondary/40 p-3 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Used this year</p>
<p className="text-sm font-semibold text-foreground">
{formatCurrency(Number(data.contributions_total), "GBP")}
</p>
</div>
<div className="rounded-md bg-secondary/40 p-3 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Remaining</p>
<p className={`text-sm font-semibold ${isDanger ? "text-red-500" : isWarning ? "text-amber-500" : "text-green-500"}`}>
{formatCurrency(Number(data.remaining), "GBP")}
</p>
</div>
<div className="rounded-md bg-secondary/40 p-3 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Carry-forward</p>
<p className="text-sm font-semibold text-foreground">
{formatCurrency(Number(data.carry_forward_total), "GBP")}
</p>
</div>
</div>
{/* Carry-forward breakdown */}
{hasCarryForward && (
<div>
<div className="flex items-center gap-1.5 mb-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Carry-forward available</p>
<span title="Unused allowance from prior 3 tax years. Oldest year must be used first.">
<Info className="w-3.5 h-3.5 text-muted-foreground cursor-help" />
</span>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b border-border">
<th className="text-left py-1.5 pr-3 font-medium">Tax year</th>
<th className="text-right py-1.5 pr-3 font-medium">Allowance</th>
<th className="text-right py-1.5 pr-3 font-medium">Used</th>
<th className="text-right py-1.5 font-medium">Unused</th>
</tr>
</thead>
<tbody>
{data.carry_forward.map((yr) => (
<tr key={yr.tax_year} className="border-b border-border/40">
<td className="py-2 pr-3 text-foreground">{taxYearDisplay(yr.tax_year)}</td>
<td className="py-2 pr-3 text-right text-muted-foreground tabular-nums">
{formatCurrency(Number(yr.standard_allowance), "GBP")}
</td>
<td className="py-2 pr-3 text-right text-muted-foreground tabular-nums">
{Number(yr.contributions) > 0 ? formatCurrency(Number(yr.contributions), "GBP") : "—"}
</td>
<td className={`py-2 text-right tabular-nums font-medium ${Number(yr.unused) > 0 ? "text-green-500" : "text-muted-foreground"}`}>
{Number(yr.unused) > 0 ? formatCurrency(Number(yr.unused), "GBP") : "—"}
</td>
</tr>
))}
<tr className="border-t border-border">
<td colSpan={3} className="py-2 pr-3 text-sm font-medium text-foreground">
Total available (standard + carry-forward)
</td>
<td className="py-2 text-right font-semibold text-foreground tabular-nums">
{formatCurrency(Number(data.total_available), "GBP")}
</td>
</tr>
</tbody>
</table>
<p className="text-xs text-muted-foreground mt-1.5">Oldest unused year must be applied first per HMRC rules.</p>
</div>
)}
{/* Tax relief summary */}
{hasRelief && (
<div className="border-t border-border pt-4 space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Tax relief {taxYearDisplay(taxYear)}
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Relief at source (auto-claimed)</span>
<span className="text-green-500 font-medium">+{formatCurrency(Number(data.relief_ras_total), "GBP")}</span>
</div>
{Number(data.relief_higher_rate_claimable) > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Higher-rate relief (40% band, via SA)</span>
<span className="text-foreground font-medium">~{formatCurrency(Number(data.relief_higher_rate_claimable), "GBP")}</span>
</div>
)}
{Number(data.relief_additional_rate_claimable) > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Additional-rate top-up (45% band, via SA)</span>
<span className="text-foreground font-medium">~{formatCurrency(Number(data.relief_additional_rate_claimable), "GBP")}</span>
</div>
)}
</div>
{Number(data.relief_higher_rate_claimable) > 0 && (
<p className="text-xs text-muted-foreground">
Higher/additional rate estimates apply only to relief-at-source contributions. Claim via Self-Assessment by 31 Jan {saDeadlineYear}.
</p>
)}
</div>
)}
</>
) : null}
</div>
);
}

View file

@ -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<ReliefType>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border">
<h2 className="text-base font-semibold text-foreground">
{existing ? "Edit contribution" : "Add contribution"}
</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{/* Date */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Date</label>
<input
type="date"
required
value={date}
onChange={(e) => 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"
/>
</div>
{/* Amounts */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Your contribution ({currency})
</label>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={memberAmount}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Employer contribution ({currency})
</label>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={employerAmount}
onChange={(e) => 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"
/>
</div>
</div>
{/* Relief type */}
<div>
<div className="flex items-center gap-2 mb-1.5">
<label className="block text-sm font-medium text-foreground">Tax relief type</label>
<button
type="button"
onClick={() => setShowReliefInfo((v) => !v)}
className="text-muted-foreground hover:text-foreground"
>
<Info className="w-4 h-4" />
</button>
</div>
{showReliefInfo && (
<div className="mb-3 rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground space-y-2">
{RELIEF_TYPES.map((rt) => (
<div key={rt.value}>
<span className="font-medium text-foreground">{rt.label}:</span> {rt.detail}
</div>
))}
</div>
)}
<div className="grid grid-cols-2 gap-2">
{RELIEF_TYPES.map((rt) => (
<button
key={rt.value}
type="button"
onClick={() => setReliefType(rt.value)}
className={`text-left rounded-md border px-3 py-2 text-sm transition-colors ${
reliefType === rt.value
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{rt.label}
</button>
))}
</div>
</div>
{/* Computed relief preview */}
{member > 0 && reliefType !== "none" && (
<div className="rounded-md border border-border bg-secondary/30 px-4 py-3 text-sm space-y-1">
<div className="flex justify-between text-muted-foreground">
<span>Gross contribution</span>
<span className="text-foreground font-medium">{formatCurrency(gross, currency)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Basic rate relief (20%)</span>
<span className="text-green-500 font-medium">+{formatCurrency(relief, currency)}</span>
</div>
{(reliefType === "relief_at_source") && (
<p className="text-xs text-muted-foreground pt-1 border-t border-border/50">
Higher/additional rate taxpayers can claim extra relief via Self-Assessment.
</p>
)}
</div>
)}
{/* Notes */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Notes <span className="font-normal text-muted-foreground">(optional)</span>
</label>
<input
type="text"
placeholder="e.g. April lump sum, salary sacrifice increase"
value={notes}
onChange={(e) => 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"
/>
</div>
{mutation.isError && (
<p className="text-sm text-destructive">Failed to save. Please try again.</p>
)}
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{mutation.isPending ? "Saving…" : existing ? "Save changes" : "Add contribution"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -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<ReliefType, string> = {
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<number, PensionContribution[]>();
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<PensionContribution | null>(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 (
<p className="text-sm text-muted-foreground py-4 text-center">
No contributions recorded yet.
</p>
);
}
const currency = "GBP";
const grouped = groupByTaxYear(contributions);
return (
<>
<div className="space-y-4 overflow-x-auto">
{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 (
<div key={taxYear}>
<div className="flex items-center gap-3 mb-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{taxYearDisplay(taxYear)} tax year
</span>
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-muted-foreground">
Member {formatCurrency(memberTotal, currency)}
{employerTotal > 0 && ` · Employer ${formatCurrency(employerTotal, currency)}`}
{reliefTotal > 0 && ` · Relief ${formatCurrency(reliefTotal, currency)}`}
</span>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b border-border">
<th className="text-left py-1.5 pr-3 font-medium">Date</th>
<th className="text-right py-1.5 pr-3 font-medium">Member</th>
<th className="text-right py-1.5 pr-3 font-medium">Employer</th>
<th className="text-right py-1.5 pr-3 font-medium">Gross</th>
<th className="text-right py-1.5 pr-3 font-medium">Relief</th>
<th className="text-left py-1.5 pr-3 font-medium">Type</th>
<th className="py-1.5 w-16" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-border/40 hover:bg-secondary/20 transition-colors">
<td className="py-2 pr-3 text-foreground whitespace-nowrap">{row.contribution_date}</td>
<td className="py-2 pr-3 text-right text-foreground tabular-nums">
{formatCurrency(Number(row.member_amount), currency)}
</td>
<td className="py-2 pr-3 text-right tabular-nums text-muted-foreground">
{Number(row.employer_amount) > 0 ? formatCurrency(Number(row.employer_amount), currency) : "—"}
</td>
<td className="py-2 pr-3 text-right tabular-nums text-foreground">
{formatCurrency(Number(row.gross_amount), currency)}
</td>
<td className="py-2 pr-3 text-right tabular-nums text-green-500">
{Number(row.relief_amount) > 0 ? `+${formatCurrency(Number(row.relief_amount), currency)}` : "—"}
</td>
<td className="py-2 pr-3">
<span className="text-xs rounded-full px-2 py-0.5 bg-secondary text-secondary-foreground">
{RELIEF_LABELS[row.relief_type]}
</span>
</td>
<td className="py-2">
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => setEditing(row)}
className="rounded p-1 text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Edit"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm("Delete this contribution?")) {
deleteMutation.mutate(row.id);
}
}}
className="rounded p-1 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
})}
</div>
{editing && (
<ContributionFormModal
accountId={accountId}
currency={currency}
existing={editing}
onClose={() => setEditing(null)}
/>
)}
</>
);
}

View file

@ -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 (
<div className="mt-4 rounded-lg border border-border bg-secondary/20 p-4 animate-pulse space-y-2">
<div className="h-4 bg-secondary/60 rounded w-40" />
<div className="h-24 bg-secondary/60 rounded" />
</div>
);
}
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 (
<div className="mt-4 space-y-4">
{/* Current year contribution meter */}
<div className="rounded-lg border border-border bg-secondary/20 p-4 space-y-3">
<div className="flex items-center gap-2">
<Gift className="w-4 h-4 text-primary" />
<h4 className="text-sm font-semibold text-foreground">LISA Current Year</h4>
</div>
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{formatCurrency(data.current_year_contributions, "GBP")} contributed</span>
<span>{formatCurrency(4000, "GBP")} limit</span>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className={`h-full rounded-full transition-all ${barColour}`}
style={{ width: `${usedPct}%` }}
/>
</div>
<div className="flex justify-between text-xs">
<span className="text-green-500 font-medium">
+{formatCurrency(data.current_year_bonus_expected, "GBP")} bonus
</span>
<span className="text-muted-foreground">
{formatCurrency(data.current_year_limit_remaining, "GBP")} remaining
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md bg-secondary/40 p-3">
<p className="text-xs text-muted-foreground mb-0.5">Total contributed</p>
<p className="text-sm font-semibold text-foreground">
{formatCurrency(data.total_contributions, "GBP")}
</p>
</div>
<div className="rounded-md bg-secondary/40 p-3">
<p className="text-xs text-muted-foreground mb-0.5">Total bonus expected</p>
<p className="text-sm font-semibold text-green-500">
+{formatCurrency(data.total_bonus_expected, "GBP")}
</p>
</div>
</div>
</div>
{/* Per-year breakdown */}
{data.tax_year_breakdown.length > 0 && (
<div className="rounded-lg border border-border bg-secondary/20 p-4 space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Year-by-year breakdown
</h4>
<div className="space-y-2">
{data.tax_year_breakdown.map((row) => (
<div key={row.tax_year} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground w-20">{taxYearDisplay(row.tax_year)}</span>
<span className="text-foreground">{formatCurrency(row.contributions, "GBP")}</span>
<span className="text-green-500 text-xs">+{formatCurrency(row.bonus_expected, "GBP")}</span>
<div className="w-20 h-1.5 rounded-full bg-secondary overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.min(100, row.limit_used_pct)}%` }}
/>
</div>
</div>
))}
</div>
</div>
)}
{/* Withdrawal penalty warning */}
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
<h4 className="text-sm font-semibold text-foreground">Withdrawal penalty</h4>
</div>
<p className="text-xs text-muted-foreground">
Withdrawing before age 60 (except for first-home purchase up to £450k) incurs a{" "}
<span className="text-foreground font-medium">25% penalty on the full withdrawal amount</span>.
Because the fund includes the government bonus, the effective loss can exceed the bonus itself.
</p>
<p className="text-xs text-muted-foreground">
If you withdrew the full balance today, the penalty would be approximately{" "}
<span className="text-amber-500 font-medium">{formatCurrency(data.withdrawal_penalty_amount, "GBP")}</span>.
</p>
</div>
{/* Key facts */}
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<Info className="w-3.5 h-3.5 shrink-0 mt-0.5" />
<span>
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.
</span>
</div>
</div>
);
}

View file

@ -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<string, string> = {
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 (
<div className="rounded-lg border border-border bg-card overflow-hidden">
{/* Card header */}
<div className="flex items-center justify-between p-4 sm:p-5">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: account.color }}
/>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-foreground truncate">{account.account_name}</span>
{meta && (
<span className="text-xs rounded-full px-2 py-0.5 bg-secondary text-secondary-foreground font-medium">
{PENSION_TYPE_LABELS[meta.pension_type] ?? meta.pension_type}
</span>
)}
</div>
{meta?.provider_name && (
<p className="text-xs text-muted-foreground mt-0.5">{meta.provider_name}</p>
)}
</div>
</div>
<div className="flex items-center gap-3 ml-4">
<div className="text-right">
<p className="font-semibold text-foreground">
{formatCurrency(Number(account.current_balance), account.currency)}
</p>
<p className="text-xs text-muted-foreground">current value</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={onEditMetadata}
title="Edit pension details"
className="rounded p-1.5 text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
<Settings2 className="w-4 h-4" />
</button>
<button
onClick={() => setExpanded((e) => !e)}
className="rounded p-1.5 text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
</div>
</div>
{/* YTD summary strip */}
{ytd && ytd.contribution_count > 0 && (
<div className="px-4 sm:px-5 pb-3 flex flex-wrap gap-4 text-sm border-t border-border/50 pt-3">
<span className="text-muted-foreground">
Member: <span className="text-foreground font-medium">{formatCurrency(ytd.member_total, account.currency)}</span>
</span>
{ytd.employer_total > 0 && (
<span className="text-muted-foreground">
Employer: <span className="text-foreground font-medium">{formatCurrency(ytd.employer_total, account.currency)}</span>
</span>
)}
<span className="text-muted-foreground">
Relief: <span className="text-green-500 font-medium">+{formatCurrency(ytd.relief_total, account.currency)}</span>
</span>
<span className="text-xs text-muted-foreground self-center">{taxYear - 1}/{String(taxYear).slice(2)} tax year</span>
</div>
)}
{/* No metadata prompt */}
{!meta && (
<div className="px-4 sm:px-5 pb-4 border-t border-border/50 pt-3">
<button
onClick={onEditMetadata}
className="text-sm text-primary hover:underline"
>
+ Add pension details (type, provider, scheme name)
</button>
</div>
)}
{/* Expanded section: contribution history + LISA info */}
{expanded && (
<div className="border-t border-border">
<div className="p-4 sm:p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">Contribution history</h3>
<button
onClick={() => setShowAddContribution(true)}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus className="w-3 h-3" />
Add contribution
</button>
</div>
<ContributionHistoryTable
accountId={account.account_id}
contributions={contributions}
/>
{meta?.pension_type === "lisa" && (
<LisaInfoCard accountId={account.account_id} />
)}
</div>
</div>
)}
{showAddContribution && (
<ContributionFormModal
accountId={account.account_id}
currency={account.currency}
onClose={() => setShowAddContribution(false)}
/>
)}
</div>
);
}

View file

@ -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<PensionMetadataCreate>({
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-xl border border-border bg-card shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border">
<h2 className="text-base font-semibold text-foreground">
{existing ? "Edit pension details" : "Add pension details"} {account.account_name}
</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form
onSubmit={(e) => {
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 */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Pension type</label>
<div className="grid grid-cols-2 gap-2">
{PENSION_TYPES.map((pt) => (
<button
key={pt.value}
type="button"
onClick={() => set("pension_type", pt.value)}
className={`text-left rounded-lg border p-3 transition-colors ${
form.pension_type === pt.value
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:border-border/80 hover:text-foreground"
}`}
>
<p className="text-sm font-medium">{pt.label}</p>
<p className="text-xs mt-0.5 leading-snug">{pt.description}</p>
</button>
))}
</div>
</div>
{/* Provider & scheme */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Provider name</label>
<input
type="text"
placeholder="e.g. Vanguard, Nest"
value={form.provider_name ?? ""}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Scheme name</label>
<input
type="text"
placeholder="e.g. NHS Pension Scheme"
value={form.scheme_name ?? ""}
onChange={(e) => 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"
/>
</div>
</div>
{/* Member reference */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Member reference <span className="text-muted-foreground font-normal">(optional)</span>
</label>
<input
type="text"
placeholder="Your scheme member number"
value={form.member_reference ?? ""}
onChange={(e) => 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"
/>
</div>
{/* DOB + target retirement age */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Date of birth</label>
<input
type="date"
value={form.dob ?? ""}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Target retirement age
</label>
<input
type="number"
min={55}
max={90}
placeholder="57"
value={form.target_retirement_age ?? ""}
onChange={(e) => 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"
/>
<p className="text-xs text-muted-foreground mt-1">Min age rises to 57 in 2028</p>
</div>
</div>
{/* Assumed growth rate */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">
Assumed annual growth rate
</label>
<div className="flex gap-2">
{[
{ label: "Cautious 2%", value: 0.02 },
{ label: "Moderate 5%", value: 0.05 },
{ label: "Growth 8%", value: 0.08 },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => set("assumed_growth_rate", opt.value)}
className={`flex-1 rounded-md border px-2 py-1.5 text-sm transition-colors ${
Number(form.assumed_growth_rate) === opt.value
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{mutation.isError && (
<p className="text-sm text-destructive">Failed to save. Please try again.</p>
)}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{mutation.isPending ? "Saving…" : "Save"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -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 (
<div className="rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className="text-xl font-semibold text-foreground">{value}</p>
{sub && <p className="text-xs text-muted-foreground mt-0.5">{sub}</p>}
</div>
);
}
export default function PensionsPage() {
const qc = useQueryClient();
const [metadataTarget, setMetadataTarget] = useState<PensionAccount | null>(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 (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<Loader2 className="animate-spin mr-2 w-5 h-5" />
Loading pensions
</div>
);
}
if (isError) {
return (
<div className="flex items-center gap-2 text-destructive p-6">
<AlertCircle className="w-5 h-5" />
Failed to load pension data.
</div>
);
}
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ShieldCheck className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold text-foreground">Pensions</h1>
</div>
<button
onClick={() => setShowAddAccount(true)}
className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Add pension account
</button>
</div>
{/* Summary cards */}
{accounts.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<SummaryCard
label="Total pension value"
value={formatCurrency(totalBalance, primaryCurrency)}
/>
<SummaryCard
label={`Member contributions (${taxYearDisplay(taxYear)})`}
value={formatCurrency(summary?.member_total ?? 0, primaryCurrency)}
/>
<SummaryCard
label={`Employer contributions (${taxYearDisplay(taxYear)})`}
value={formatCurrency(summary?.employer_total ?? 0, primaryCurrency)}
/>
<SummaryCard
label={`Tax relief received (${taxYearDisplay(taxYear)})`}
value={formatCurrency(summary?.relief_total ?? 0, primaryCurrency)}
sub="Basic rate (auto-claimed)"
/>
</div>
)}
{/* Pension account cards */}
{accounts.length === 0 ? (
<div className="rounded-lg border border-border bg-card p-12 text-center">
<ShieldCheck className="w-10 h-10 mx-auto text-muted-foreground mb-3" />
<p className="text-foreground font-medium mb-1">No pension accounts yet</p>
<p className="text-sm text-muted-foreground mb-4">
Add your first pension to start tracking contributions and tax relief.
</p>
<button
onClick={() => setShowAddAccount(true)}
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Add pension account
</button>
</div>
) : (
<div className="space-y-4">
{accounts.map((account) => (
<PensionAccountCard
key={account.account_id}
account={account}
onEditMetadata={() => setMetadataTarget(account)}
/>
))}
</div>
)}
{accounts.length > 0 && (
<AnnualAllowanceCard initialTaxYear={taxYear} />
)}
{accounts.length > 0 && (
<>
<h2 className="text-lg font-semibold text-foreground">Retirement Planning</h2>
<StatePensionWidget />
<div className="space-y-4">
{accounts.map((account) => (
<RetirementProjectionCard
key={account.account_id}
accountId={account.account_id}
accountName={account.account_name}
hasMetadata={!!(account.metadata?.dob && account.metadata?.target_retirement_age)}
onEditMetadata={() => setMetadataTarget(account)}
/>
))}
</div>
</>
)}
{showAddAccount && (
<AccountFormModal
defaultType="pension"
onClose={() => setShowAddAccount(false)}
onSubmit={(data: AccountCreate) => createMutation.mutate(data)}
isLoading={createMutation.isPending}
/>
)}
{metadataTarget && (
<PensionMetadataModal
account={metadataTarget}
onClose={() => setMetadataTarget(null)}
/>
)}
</div>
);
}

View file

@ -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 (
<div className="rounded-lg border border-border bg-card p-5 text-center">
<TrendingUp className="w-8 h-8 mx-auto text-muted-foreground mb-2" />
<p className="text-sm font-medium text-foreground mb-1">{accountName}</p>
<p className="text-sm text-muted-foreground mb-3">
Add your date of birth and target retirement age to see a retirement projection.
</p>
<button
onClick={onEditMetadata}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
<Settings2 className="w-3.5 h-3.5" />
Add pension details
</button>
</div>
);
}
if (isLoading) {
return (
<div className="rounded-lg border border-border bg-card p-5 animate-pulse space-y-3">
<div className="h-4 bg-secondary/60 rounded w-40" />
<div className="h-48 bg-secondary/60 rounded" />
</div>
);
}
if (error || !data) {
const msg = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
return (
<div className="rounded-lg border border-border bg-card p-5">
<p className="text-sm font-medium text-foreground mb-1">{accountName}</p>
<p className="text-sm text-muted-foreground">
{msg ?? "Could not load projection. Ensure date of birth and target retirement age are set in pension details."}
</p>
<button onClick={onEditMetadata} className="mt-2 text-sm text-primary hover:underline inline-flex items-center gap-1">
<Settings2 className="w-3.5 h-3.5" /> Edit pension details
</button>
</div>
);
}
const moderateScenario = data.scenarios.find((s) => s.growth_rate === 0.05) ?? data.scenarios[1];
return (
<div className="rounded-lg border border-border bg-card p-5 space-y-5">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h3 className="text-sm font-semibold text-foreground">{data.account_name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{data.years_to_retirement} years to retirement · target age {data.target_retirement_age}
</p>
</div>
<p className="text-xs text-muted-foreground">
Current: {formatCurrency(Number(data.current_balance), "GBP")}
</p>
</div>
{/* Scenario pills */}
<div className="grid grid-cols-3 gap-3">
{data.scenarios.map((s) => (
<div key={s.label} className="rounded-md bg-secondary/40 p-3">
<p className="text-xs text-muted-foreground mb-1">{s.label}</p>
<p className="text-sm font-semibold text-foreground">
{formatCurrency(Number(s.projected_pot), "GBP")}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatCurrency(Number(s.annual_drawdown_4pct), "GBP")}/yr at 4%
</p>
{data.state_pension_annual && (
<p className="text-xs text-green-500 mt-0.5">
+{formatCurrency(Number(data.state_pension_annual), "GBP")} SP
</p>
)}
</div>
))}
</div>
{/* Chart */}
{data.chart_data.length > 1 && (
<div className="h-52">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data.chart_data} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<defs>
{(["pot_2pct", "pot_5pct", "pot_8pct"] as const).map((key) => (
<linearGradient key={key} id={`grad-${key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={SCENARIO_COLOURS[key]} stopOpacity={0.3} />
<stop offset="95%" stopColor={SCENARIO_COLOURS[key]} stopOpacity={0.0} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="year"
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={formatYAxis}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
tickLine={false}
axisLine={false}
width={52}
/>
<Tooltip
{...TOOLTIP_STYLE}
formatter={(value: number, name: string) => [
formatCurrency(value, "GBP"),
SCENARIO_LABELS[name as keyof typeof SCENARIO_LABELS] ?? name,
]}
/>
<Legend
formatter={(value) => SCENARIO_LABELS[value as keyof typeof SCENARIO_LABELS] ?? value}
wrapperStyle={{ fontSize: 11, paddingTop: 8 }}
/>
{(["pot_2pct", "pot_5pct", "pot_8pct"] as const).map((key) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={SCENARIO_COLOURS[key]}
strokeWidth={2}
fill={`url(#grad-${key})`}
dot={false}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
)}
{/* State pension note */}
{data.state_pension_annual && (
<p className="text-xs text-muted-foreground border-t border-border pt-3">
State Pension adds {formatCurrency(Number(data.state_pension_annual), "GBP")}/yr from age {data.state_pension_age},
bringing moderate scenario total to{" "}
<span className="text-foreground font-medium">
{formatCurrency(Number(moderateScenario.annual_drawdown_4pct) + Number(data.state_pension_annual), "GBP")}/yr
</span>.
</p>
)}
<p className="text-xs text-muted-foreground">
Projections are estimates based on assumed growth rates. Not financial advice.
</p>
</div>
);
}

View file

@ -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 (
<div className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-foreground">State Pension</h2>
<div className="flex items-center gap-2">
<a
href="https://www.gov.uk/check-state-pension"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Check on gov.uk <ExternalLink className="w-3 h-3" />
</a>
{!editing && (
<button
onClick={startEdit}
className="rounded p-1.5 text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Edit NI record"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{editing ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-foreground mb-1">
Qualifying NI years
</label>
<input
type="number"
min={0}
max={50}
value={years}
onChange={(e) => 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
/>
<p className="text-xs text-muted-foreground mt-1">35 years needed for full State Pension</p>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">
Date checked <span className="font-normal text-muted-foreground">(optional)</span>
</label>
<input
type="date"
value={checkedDate}
onChange={(e) => 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"
/>
</div>
</div>
{mutation.isError && (
<p className="text-xs text-destructive">Failed to save. Try again.</p>
)}
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={mutation.isPending}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Check className="w-3.5 h-3.5" />
{mutation.isPending ? "Saving…" : "Save"}
</button>
<button
onClick={() => setEditing(false)}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
Cancel
</button>
</div>
</div>
) : isLoading ? (
<div className="animate-pulse space-y-2">
<div className="h-4 bg-secondary/60 rounded w-48" />
<div className="h-3 bg-secondary/60 rounded w-36" />
</div>
) : data ? (
<div className="space-y-3">
{/* NI record progress */}
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{data.qualifying_years} qualifying years</span>
<span>35 needed for full pension</span>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className={`h-full rounded-full ${data.is_full_pension ? "bg-green-500" : "bg-primary"}`}
style={{ width: `${Math.min(100, (data.qualifying_years / 35) * 100)}%` }}
/>
</div>
{!data.is_full_pension && (
<p className="text-xs text-muted-foreground">{data.years_to_full} more {data.years_to_full === 1 ? "year" : "years"} needed for full pension</p>
)}
</div>
{/* Projected amounts */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md bg-secondary/40 p-3">
<p className="text-xs text-muted-foreground mb-0.5">Weekly amount</p>
<p className="text-sm font-semibold text-foreground">{formatCurrency(Number(data.weekly_amount), "GBP")}</p>
</div>
<div className="rounded-md bg-secondary/40 p-3">
<p className="text-xs text-muted-foreground mb-0.5">Annual amount</p>
<p className="text-sm font-semibold text-foreground">{formatCurrency(Number(data.annual_amount), "GBP")}</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
) : (
<div className="text-center py-4">
<p className="text-sm text-muted-foreground mb-3">
Enter your NI qualifying years from your{" "}
<a href="https://www.gov.uk/check-state-pension" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
gov.uk State Pension statement
</a>{" "}
to see your projected State Pension.
</p>
<button
onClick={startEdit}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
Add NI record
</button>
</div>
)}
</div>
);
}

View file

@ -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 (
<div className="flex justify-between items-baseline py-1.5 border-b border-border/50 last:border-0">
<span className="text-sm text-muted-foreground">{label}</span>
<span
className={[
"text-sm font-medium tabular-nums",
highlight === "green" ? "text-green-500" : "",
highlight === "yellow" ? "text-yellow-500" : "",
!highlight ? "text-foreground" : "",
]
.filter(Boolean)
.join(" ")}
>
{value}
</span>
</div>
);
}
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 (
<div className="rounded-lg border border-border bg-card p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-primary" />
<h2 className="text-base font-semibold">Pension contributions</h2>
</div>
<Link
to="/pensions"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
View pensions <ExternalLink className="w-3 h-3" />
</Link>
</div>
{/* Annual allowance bar */}
<div className="space-y-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Annual allowance used</span>
<span>{gbp(p.annual_allowance_used)} of {gbp(p.standard_allowance)}</span>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className={`h-full rounded-full transition-all ${barColour}`}
style={{ width: `${usedPct}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{gbp(p.annual_allowance_remaining)} remaining contributions above £60,000 are subject to the Annual Allowance Charge.
</p>
</div>
{/* Contribution breakdown */}
<div className="rounded-md bg-secondary/30 p-4 space-y-0.5">
{hasNetPay && (
<Kv
label="Net pay arrangement"
value={gbp(p.net_pay_total)}
/>
)}
{hasSalSac && (
<Kv
label="Salary sacrifice"
value={gbp(p.salary_sacrifice_total)}
/>
)}
{hasRas && (
<Kv
label="Relief at source (gross)"
value={gbp(p.ras_gross_total)}
/>
)}
</div>
{/* Relief notes */}
{(hasNetPay || hasSalSac) && (
<p className="text-xs text-muted-foreground">
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.
</p>
)}
{/* Higher/additional rate relief to claim */}
{showReliefClaim && (
<div className="rounded-md border border-green-500/30 bg-green-500/5 p-4 space-y-2">
<p className="text-sm font-medium text-foreground">
Higher-rate relief to claim via Self Assessment
</p>
<p className="text-sm text-green-500 font-semibold tabular-nums">
{gbp(String(totalClaimable))}
</p>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
)}
</div>
);
}

View file

@ -11,6 +11,7 @@ import CGTSection from "./CGTSection";
import DividendSection from "./DividendSection"; import DividendSection from "./DividendSection";
import OverallLiabilityCard from "./OverallLiabilityCard"; import OverallLiabilityCard from "./OverallLiabilityCard";
import RateConfigModal from "./RateConfigModal"; import RateConfigModal from "./RateConfigModal";
import PensionTaxSection from "./PensionTaxSection";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Loading skeleton // Loading skeleton
@ -220,6 +221,7 @@ export default function TaxPage() {
<TaxProfileCard taxYear={taxYear} /> <TaxProfileCard taxYear={taxYear} />
<PayslipTable taxYear={taxYear} /> <PayslipTable taxYear={taxYear} />
<TaxNISummaryCard report={report} /> <TaxNISummaryCard report={report} />
<PensionTaxSection report={report} />
<CGTSection taxYear={taxYear} report={report} /> <CGTSection taxYear={taxYear} report={report} />
<DividendSection report={report} /> <DividendSection report={report} />
<OverallLiabilityCard report={report} /> <OverallLiabilityCard report={report} />