Add pensions module and integrate with tax report
Adds a full pensions feature: SIPP/workplace DC/LISA account metadata, contribution recording with relief-at-source/net-pay/salary-sacrifice gross calculations, state pension tracker, annual allowance monitor, and LISA summary. Pension contributions feed into the tax report (RAS gross totals, allowance used). Includes two Alembic migrations, backend service/schema/API, and full frontend pensions page with cards for allowance, state pension, LISA, and retirement projection. Also fixes CSRF cookie secure flag (must be false for HTTP deployments) and extends tax schemas/service to expose pension data in the report. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b30e8e577b
commit
1a2c8efd01
30 changed files with 3537 additions and 8 deletions
135
frontend/src/pages/pensions/LisaInfoCard.tsx
Normal file
135
frontend/src/pages/pensions/LisaInfoCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue