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

@ -11,7 +11,7 @@ from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decrypt_field, encrypt_field
@ -595,6 +595,7 @@ async def build_tax_report(
from app.db.models.investment_transaction import InvestmentTransaction
from app.db.models.investment_holding import InvestmentHolding
from app.db.models.asset import Asset
from app.db.models.pension import PensionContribution, PensionMetadata
rates = await load_rates(db, user_id, tax_year)
start_date, end_date = tax_year_date_range(tax_year)
@ -676,6 +677,43 @@ async def build_tax_report(
"amount": str(amount),
})
# ---- Pension contributions ----
_PENSION_ALLOWANCE = Decimal("60000")
pension_result = await db.execute(
select(
PensionContribution.relief_type,
func.coalesce(func.sum(PensionContribution.member_amount), 0).label("member_total"),
func.coalesce(func.sum(PensionContribution.gross_amount), 0).label("gross_total"),
)
.join(PensionMetadata, PensionContribution.pension_id == PensionMetadata.id)
.where(
PensionContribution.user_id == user_id,
PensionContribution.tax_year == tax_year,
PensionMetadata.pension_type != "lisa",
)
.group_by(PensionContribution.relief_type)
)
pension_rows = pension_result.all()
net_pay_total = Decimal("0")
salary_sacrifice_total = Decimal("0")
ras_gross_total = Decimal("0")
annual_allowance_used = Decimal("0")
for p_row in pension_rows:
gross = Decimal(str(p_row.gross_total))
annual_allowance_used += gross
if p_row.relief_type == "net_pay":
net_pay_total += Decimal(str(p_row.member_total))
elif p_row.relief_type == "salary_sacrifice":
salary_sacrifice_total += Decimal(str(p_row.member_total))
elif p_row.relief_type == "relief_at_source":
ras_gross_total += gross
annual_allowance_remaining = max(Decimal("0"), _PENSION_ALLOWANCE - annual_allowance_used)
higher_rate_claimable = (ras_gross_total * Decimal("0.20")).quantize(Decimal("0.01"))
additional_rate_claimable = (ras_gross_total * Decimal("0.05")).quantize(Decimal("0.01"))
has_pension_data = annual_allowance_used > 0
# ---- Calculations ----
income_tax_result = calculate_income_tax(gross_income, tax_code, rates)
ni_result = calculate_ni(gross_income, rates)
@ -735,6 +773,16 @@ async def build_tax_report(
for k, v in dividend_result.items()},
"dividend_transactions": dividend_rows,
},
"pensions": {
"net_pay_total": str(net_pay_total),
"salary_sacrifice_total": str(salary_sacrifice_total),
"ras_gross_total": str(ras_gross_total),
"higher_rate_claimable": str(higher_rate_claimable),
"additional_rate_claimable": str(additional_rate_claimable),
"annual_allowance_used": str(annual_allowance_used),
"annual_allowance_remaining": str(annual_allowance_remaining),
"standard_allowance": str(_PENSION_ALLOWANCE),
} if has_pension_data else None,
"summary": {
"total_liability": str(total_liability),
"total_withheld": str(total_withheld),