From da59fa9f23113f7d52232a54e2cdda16729d60f8 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 23 Apr 2026 12:44:14 +0000 Subject: [PATCH 1/3] wip: transaction form and list updates (pre-theme-fixes snapshot) Co-Authored-By: Claude Sonnet 4.6 --- .../transactions/TransactionFormModal.tsx | 40 ++++++++++++++++--- .../pages/transactions/TransactionList.tsx | 26 ++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/transactions/TransactionFormModal.tsx b/frontend/src/pages/transactions/TransactionFormModal.tsx index 14b6700..7382318 100644 --- a/frontend/src/pages/transactions/TransactionFormModal.tsx +++ b/frontend/src/pages/transactions/TransactionFormModal.tsx @@ -2,7 +2,7 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { format } from "date-fns"; -import { X, Loader2, Sparkles } from "lucide-react"; +import { X, Loader2, Sparkles, RotateCcw } from "lucide-react"; import type { Account } from "@/api/accounts"; const schema = z.object({ @@ -40,9 +40,11 @@ interface Props { initialValues?: TransactionInitialValues; parsedFromReceipt?: boolean; showAiDebug?: boolean; + onRescan?: () => void; + rescanLoading?: boolean; } -export default function TransactionFormModal({ accounts, categories, onClose, onSubmit, isLoading, initialValues, parsedFromReceipt, showAiDebug }: Props) { +export default function TransactionFormModal({ accounts, categories, onClose, onSubmit, isLoading, initialValues, parsedFromReceipt, showAiDebug, onRescan, rescanLoading }: Props) { const { register, handleSubmit, watch, formState: { errors } } = useForm
({ resolver: zodResolver(schema), defaultValues: { @@ -89,14 +91,40 @@ export default function TransactionFormModal({ accounts, categories, onClose, on {parsedFromReceipt && !showAiDebug && (
- Fields pre-filled from receipt — review before saving + Fields pre-filled from receipt — review before saving + {onRescan && ( + + )}
)} {parsedFromReceipt && showAiDebug && (
-

- AI scan result — review before saving -

+
+

+ AI scan result — review before saving +

+ {onRescan && ( + + )} +
Merchant{initialValues?.merchant ?? not detected} Amount{initialValues?.amount != null ? initialValues.amount : not detected} diff --git a/frontend/src/pages/transactions/TransactionList.tsx b/frontend/src/pages/transactions/TransactionList.tsx index f2d044b..3be9dce 100644 --- a/frontend/src/pages/transactions/TransactionList.tsx +++ b/frontend/src/pages/transactions/TransactionList.tsx @@ -55,6 +55,8 @@ export default function TransactionList() { const receiptFileRef = useRef(null); const [scanError, setScanError] = useState(null); const [scanning, setScanning] = useState(false); + const [rescanLoading, setRescanLoading] = useState(false); + const [rescanKey, setRescanKey] = useState(0); const receiptInputRef = useRef(null); const [search, setSearch] = useState(""); const [filterType, setFilterType] = useState(""); @@ -113,6 +115,27 @@ export default function TransactionList() { } } + async function handleRescan() { + if (!receiptFileRef.current) return; + setRescanLoading(true); + setScanError(null); + try { + const parsed = await parseReceiptFile(receiptFileRef.current); + setReceiptParsed(parsed); + setRescanKey((k) => k + 1); + } catch (e: any) { + const detail = e?.response?.data?.detail; + const status = e?.response?.status; + if (detail) { + setScanError(typeof detail === "string" ? detail : `HTTP ${status}: ${JSON.stringify(detail)}`); + } else { + setScanError(`Rescan failed — ${e?.message ?? "unknown error"}`); + } + } finally { + setRescanLoading(false); + } + } + async function handleReceiptFile(file: File) { setScanning(true); setScanError(null); @@ -368,6 +391,7 @@ export default function TransactionList() { {showForm && ( { setShowForm(false); setReceiptParsed(null); receiptFileRef.current = null; }} @@ -384,6 +408,8 @@ export default function TransactionList() { } : undefined} parsedFromReceipt={!!receiptParsed} showAiDebug={aiSettings?.debug ?? false} + onRescan={handleRescan} + rescanLoading={rescanLoading} /> )} From 0b326cbd877bac586a30ba86ae50ff623a68ac71 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 23 Apr 2026 12:58:06 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20theme=20consistency=20=E2=80=94=20ch?= =?UTF-8?q?art=20colours,=20axis=20readability,=20warning=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recharts axes: add fill to tick objects so labels are visible on dark themes - Recharts axes: wrap stroke/gridcolor in hsl() so var() resolves to valid colour - Chart primary lines/gradients: replace hardcoded #6366f1 with hsl(var(--primary)) so charts adopt each theme's accent (gold on Vault, green on Terminal, etc.) - Plotly charts: add cssVar() helper (reads getComputedStyle) to pass actual computed colour strings instead of unresolved var() references - Budget radial gauge: use hsl(var(--destructive/success/warning)) for SVG colours - Add --warning CSS variable to all 7 themes with per-theme appropriate values; wire into Tailwind config as themed colour - Replace all text-yellow-500 / text-orange-500 / bg-yellow-500 with text-warning / bg-warning across Dashboard, Budget, Account, Predictions, Settings Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/index.css | 8 +++ frontend/src/pages/accounts/AccountDetail.tsx | 6 +- frontend/src/pages/accounts/AccountList.tsx | 2 +- frontend/src/pages/budgets/BudgetPage.tsx | 12 ++-- frontend/src/pages/dashboard/Dashboard.tsx | 22 +++---- .../src/pages/investments/AssetDetail.tsx | 13 +++-- .../src/pages/predictions/PredictionsPage.tsx | 57 +++++++++--------- frontend/src/pages/reports/ReportsPage.tsx | 58 +++++++++---------- frontend/src/pages/settings/SettingsPage.tsx | 2 +- frontend/src/utils/cssVar.ts | 21 +++++++ 10 files changed, 118 insertions(+), 83 deletions(-) create mode 100644 frontend/src/utils/cssVar.ts diff --git a/frontend/src/index.css b/frontend/src/index.css index 7ee97c9..cb4d240 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -23,6 +23,7 @@ --ring: 252 87% 67%; --radius: 0.6rem; --success: 142 71% 45%; + --warning: 38 92% 58%; } /* ─── Base font ─────────────────────────────────────────────────────────────── */ @@ -71,6 +72,7 @@ --ring: 252 87% 67%; --radius: 0.6rem; --success: 142 71% 45%; + --warning: 38 92% 58%; } /* ══════════════════════════════════════════════════════════════════════════════ @@ -97,6 +99,7 @@ --ring: 252 87% 55%; --radius: 0.6rem; --success: 142 71% 40%; + --warning: 38 90% 46%; } /* ══════════════════════════════════════════════════════════════════════════════ @@ -123,6 +126,7 @@ --ring: 252 87% 70%; --radius: 0.5rem; --success: 142 71% 45%; + --warning: 38 92% 62%; } /* ══════════════════════════════════════════════════════════════════════════════ @@ -149,6 +153,7 @@ --ring: 38 92% 50%; --radius: 0.4rem; --success: 142 55% 42%; + --warning: 38 92% 58%; } /* ══════════════════════════════════════════════════════════════════════════════ @@ -175,6 +180,7 @@ --ring: 120 100% 50%; --radius: 0.2rem; --success: 120 100% 50%; + --warning: 60 100% 55%; } .theme-terminal body, @@ -243,6 +249,7 @@ --ring: 330 100% 62%; --radius: 0.5rem; --success: 165 100% 45%; + --warning: 55 100% 65%; } .theme-synthwave h1, @@ -307,6 +314,7 @@ --ring: 0 65% 38%; --radius: 0.2rem; --success: 142 50% 32%; + --warning: 38 90% 46%; } .theme-ledger body, diff --git a/frontend/src/pages/accounts/AccountDetail.tsx b/frontend/src/pages/accounts/AccountDetail.tsx index 2ecc6d2..771cbec 100644 --- a/frontend/src/pages/accounts/AccountDetail.tsx +++ b/frontend/src/pages/accounts/AccountDetail.tsx @@ -95,13 +95,13 @@ export default function AccountDetail() {
Credit Utilisation - 80 ? "text-destructive" : utilPct > 50 ? "text-yellow-500" : "text-success")}> + 80 ? "text-destructive" : utilPct > 50 ? "text-warning" : "text-success")}> {utilPct.toFixed(0)}%
80 ? "bg-destructive" : utilPct > 50 ? "bg-yellow-500" : "bg-success")} + className={cn("h-full rounded-full transition-all", utilPct > 80 ? "bg-destructive" : utilPct > 50 ? "bg-warning" : "bg-success")} style={{ width: `${utilPct}%` }} />
@@ -298,7 +298,7 @@ function ImportModal({ {/* Detected format badge */}
{preview.detected_format ? ( <> Detected: {preview.detected_format} diff --git a/frontend/src/pages/accounts/AccountList.tsx b/frontend/src/pages/accounts/AccountList.tsx index b5a18e9..45e0a00 100644 --- a/frontend/src/pages/accounts/AccountList.tsx +++ b/frontend/src/pages/accounts/AccountList.tsx @@ -315,7 +315,7 @@ function AccountGroup({
80 ? "bg-destructive" : utilPct > 50 ? "bg-yellow-500" : "bg-success")} + className={cn("h-full rounded-full transition-all", utilPct > 80 ? "bg-destructive" : utilPct > 50 ? "bg-warning" : "bg-success")} style={{ width: `${utilPct}%` }} />
diff --git a/frontend/src/pages/budgets/BudgetPage.tsx b/frontend/src/pages/budgets/BudgetPage.tsx index 3bf6d33..170cbca 100644 --- a/frontend/src/pages/budgets/BudgetPage.tsx +++ b/frontend/src/pages/budgets/BudgetPage.tsx @@ -12,7 +12,11 @@ function RadialGauge({ percent, size = 80 }: { percent: number; size?: number }) const circumference = 2 * Math.PI * r; const clamped = Math.min(percent, 100); const offset = circumference - (clamped / 100) * circumference; - const color = percent >= 100 ? "#ef4444" : percent >= 80 ? "#f97316" : "#22c55e"; + const color = percent >= 100 + ? "hsl(var(--destructive))" + : percent >= 80 + ? "hsl(var(--warning))" + : "hsl(var(--success))"; return ( @@ -105,7 +109,7 @@ export default function BudgetPage() { · {overBudget} over budget )} {alerted > 0 && ( - · {alerted} near limit + · {alerted} near limit )}

@@ -139,7 +143,7 @@ export default function BudgetPage() { item.is_over_budget ? "border-destructive/50" : item.alert_triggered - ? "border-orange-500/50" + ? "border-warning/50" : "border-border" )} > @@ -170,7 +174,7 @@ export default function BudgetPage() { {item.is_over_budget ? ( ) : item.alert_triggered ? ( - + ) : ( )} diff --git a/frontend/src/pages/dashboard/Dashboard.tsx b/frontend/src/pages/dashboard/Dashboard.tsx index a530470..ddaba24 100644 --- a/frontend/src/pages/dashboard/Dashboard.tsx +++ b/frontend/src/pages/dashboard/Dashboard.tsx @@ -53,13 +53,13 @@ export default function Dashboard() { {/* 2FA nudge */} {!totpEnabled && ( -
- +
+

- Enable two-factor authentication + Enable two-factor authentication to secure your account.

- + Set up 2FA
@@ -111,14 +111,14 @@ export default function Dashboard() { ({ date: p.date, value: Number(p.net_worth) }))}> - - + + - - `£${(v/1000).toFixed(0)}k`} width={45} /> + + `£${(v/1000).toFixed(0)}k`} width={45} /> formatCurrency(v, nwReport.base_currency)} /> - + ) : ( @@ -136,8 +136,8 @@ export default function Dashboard() { {ieReport && ieReport.points.length > 0 ? ( ({ month: p.month, income: Number(p.income), expenses: Number(p.expenses) }))}> - - `£${(v/1000).toFixed(0)}k`} width={45} /> + + `£${(v/1000).toFixed(0)}k`} width={45} /> formatCurrency(v, "GBP")} /> diff --git a/frontend/src/pages/investments/AssetDetail.tsx b/frontend/src/pages/investments/AssetDetail.tsx index 9898267..d1827b5 100644 --- a/frontend/src/pages/investments/AssetDetail.tsx +++ b/frontend/src/pages/investments/AssetDetail.tsx @@ -6,6 +6,7 @@ import { useUiStore } from "@/store/uiStore"; import { cn } from "@/utils/cn"; import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react"; import Plot from "react-plotly.js"; +import { cssVar } from "@/utils/cssVar"; export default function AssetDetail() { const { assetId } = useParams<{ assetId: string }>(); @@ -152,8 +153,8 @@ export default function AssetDetail() { high: highs as number[], low: lows as number[], close: closes as number[], - increasing: { line: { color: "#22c55e" } }, - decreasing: { line: { color: "#ef4444" } }, + increasing: { line: { color: cssVar("--success") } }, + decreasing: { line: { color: cssVar("--destructive") } }, name: holding?.symbol ?? "Price", }, { @@ -161,16 +162,16 @@ export default function AssetDetail() { x: dates, y: volumes as number[], yaxis: "y2", - marker: { color: "rgba(99,102,241,0.3)" }, + marker: { color: cssVar("--primary", 0.3) }, name: "Volume", }, ]} layout={{ paper_bgcolor: "transparent", plot_bgcolor: "transparent", - font: { color: "var(--muted-foreground)", size: 11 }, - xaxis: { rangeslider: { visible: false }, gridcolor: "var(--border)", showgrid: true }, - yaxis: { gridcolor: "var(--border)", showgrid: true, domain: [0.25, 1] }, + font: { color: cssVar("--muted-foreground"), size: 11 }, + xaxis: { rangeslider: { visible: false }, gridcolor: cssVar("--border"), showgrid: true }, + yaxis: { gridcolor: cssVar("--border"), showgrid: true, domain: [0.25, 1] }, yaxis2: { domain: [0, 0.2], showgrid: false }, margin: { t: 10, r: 10, b: 40, l: 60 }, showlegend: false, diff --git a/frontend/src/pages/predictions/PredictionsPage.tsx b/frontend/src/pages/predictions/PredictionsPage.tsx index 42cbad3..9317ffa 100644 --- a/frontend/src/pages/predictions/PredictionsPage.tsx +++ b/frontend/src/pages/predictions/PredictionsPage.tsx @@ -12,6 +12,7 @@ import { XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, ReferenceLine, } from "recharts"; import Plot from "react-plotly.js"; +import { cssVar } from "@/utils/cssVar"; const TABS = [ { id: "spending", label: "Spending", icon: BarChart3 }, @@ -105,11 +106,11 @@ function SpendingTab() {
- - `£${v}`} width={55} /> + + `£${v}`} width={55} /> formatCurrency(v, "GBP")} /> - - + + @@ -146,13 +147,13 @@ function BudgetAlerts() {
{f.category_name} - 0.75 ? "text-destructive" : "text-yellow-500")}> + 0.75 ? "text-destructive" : "text-warning")}> {(f.probability_overspend * 100).toFixed(0)}% overspend risk
0.75 ? "bg-destructive" : "bg-yellow-500")} + className={cn("h-full rounded-full", f.probability_overspend > 0.75 ? "bg-destructive" : "bg-warning")} style={{ width: `${Math.min(100, forecastPct)}%` }} />
@@ -238,12 +239,12 @@ function NetWorthTab() {

Net Worth Projection

- - `£${(v / 1000).toFixed(0)}k`} width={55} /> + + `£${(v / 1000).toFixed(0)}k`} width={55} /> formatCurrency(v, "GBP")} /> - {lastHistory && } - + {lastHistory && } + @@ -342,8 +343,8 @@ function MonteCarloTab() { x: data.percentiles.p90.map(p => p.date), y: data.percentiles.p90.map(p => p.value), fill: "tonexty", - fillcolor: "rgba(99,102,241,0.15)", - line: { color: "#6366f1", width: 1 }, + fillcolor: cssVar("--primary", 0.15), + line: { color: cssVar("--primary"), width: 1 }, name: "P90", mode: "lines", }, @@ -352,8 +353,8 @@ function MonteCarloTab() { x: data.percentiles.p75.map(p => p.date), y: data.percentiles.p75.map(p => p.value), fill: "tonexty", - fillcolor: "rgba(99,102,241,0.2)", - line: { color: "#6366f1", width: 1 }, + fillcolor: cssVar("--primary", 0.2), + line: { color: cssVar("--primary"), width: 1 }, name: "P75", mode: "lines", }, @@ -361,7 +362,7 @@ function MonteCarloTab() { type: "scatter" as const, x: data.percentiles.p50.map(p => p.date), y: data.percentiles.p50.map(p => p.value), - line: { color: "#22c55e", width: 2.5 }, + line: { color: cssVar("--success"), width: 2.5 }, name: "P50 (Median)", mode: "lines", }, @@ -370,8 +371,8 @@ function MonteCarloTab() { x: data.percentiles.p25.map(p => p.date), y: data.percentiles.p25.map(p => p.value), fill: "tonexty", - fillcolor: "rgba(239,68,68,0.1)", - line: { color: "#ef4444", width: 1 }, + fillcolor: cssVar("--destructive", 0.1), + line: { color: cssVar("--destructive"), width: 1 }, name: "P25", mode: "lines", }, @@ -380,8 +381,8 @@ function MonteCarloTab() { x: data.percentiles.p10.map(p => p.date), y: data.percentiles.p10.map(p => p.value), fill: "tonexty", - fillcolor: "rgba(239,68,68,0.15)", - line: { color: "#ef4444", width: 1 }, + fillcolor: cssVar("--destructive", 0.15), + line: { color: cssVar("--destructive"), width: 1 }, name: "P10", mode: "lines", }, @@ -389,10 +390,10 @@ function MonteCarloTab() { layout={{ paper_bgcolor: "transparent", plot_bgcolor: "transparent", - font: { color: "var(--muted-foreground)", size: 11 }, - xaxis: { gridcolor: "var(--border)", showgrid: true }, + font: { color: cssVar("--muted-foreground"), size: 11 }, + xaxis: { gridcolor: cssVar("--border"), showgrid: true }, yaxis: { - gridcolor: "var(--border)", + gridcolor: cssVar("--border"), showgrid: true, tickformat: "£,.0f", }, @@ -461,15 +462,15 @@ function CashFlowTab() { - - + + - v.slice(5)} /> - `£${(v / 1000).toFixed(1)}k`} width={55} /> + v.slice(5)} /> + `£${(v / 1000).toFixed(1)}k`} width={55} /> formatCurrency(v, "GBP")} /> - - + +
diff --git a/frontend/src/pages/reports/ReportsPage.tsx b/frontend/src/pages/reports/ReportsPage.tsx index fa2abf3..6524bdc 100644 --- a/frontend/src/pages/reports/ReportsPage.tsx +++ b/frontend/src/pages/reports/ReportsPage.tsx @@ -249,15 +249,15 @@ function NetWorthTab() { ({ ...p, net_worth: Number(p.net_worth), total_assets: Number(p.total_assets), total_liabilities: Number(p.total_liabilities) }))}> - - + + - - - `£${(v/1000).toFixed(0)}k`} /> + + + `£${(v/1000).toFixed(0)}k`} /> formatCurrency(v, data.base_currency)} /> - + @@ -287,9 +287,9 @@ function IncomeExpenseTab() {

Monthly Income vs Expenses

- - - `£${(v/1000).toFixed(0)}k`} /> + + + `£${(v/1000).toFixed(0)}k`} /> formatCurrency(v, data.currency)} /> @@ -340,15 +340,15 @@ function CashFlowTab() {

Daily Cash Flow — Last 30 Days

- - - `£${(v/1000).toFixed(1)}k`} /> - `£${(v/1000).toFixed(1)}k`} /> + + + `£${(v/1000).toFixed(1)}k`} /> + `£${(v/1000).toFixed(1)}k`} /> formatCurrency(v, data.currency)} /> - +
@@ -404,15 +404,15 @@ function SavingsRateTab() {

Savings Rate by Month

- - - `£${(v/1000).toFixed(0)}k`} /> - `${v}%`} /> + + + `£${(v/1000).toFixed(0)}k`} /> + `${v}%`} /> name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} /> - +
@@ -580,12 +580,12 @@ function BudgetVsActualTab() {

Budget vs Actual Spending

- - `£${v}`} /> - + + `£${v}`} /> + formatCurrency(v, data.currency)} /> - + @@ -614,9 +614,9 @@ function SpendingTrendsTab() {

Spending by Category (6 months)

- - - `£${v}`} /> + + + `£${v}`} /> formatCurrency(v, data.currency)} /> {data.categories.slice(0, 8).map((cat, i) => ( @@ -674,9 +674,9 @@ function InvestmentsTab() {

Holdings Value

- - `£${(v/1000).toFixed(0)}k`} /> - + + `£${(v/1000).toFixed(0)}k`} /> + formatCurrency(v, perf.currency)} /> {holdingsData.map((entry, i) => ( diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index f8941c7..9e4dd9d 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -234,7 +234,7 @@ function PasswordCard() {
{[1,2,3,4].map(i => { const score = Math.min(4, Math.floor(next.length / 3)); - return
; + return
; })}
)} diff --git a/frontend/src/utils/cssVar.ts b/frontend/src/utils/cssVar.ts new file mode 100644 index 0000000..f3bcf8b --- /dev/null +++ b/frontend/src/utils/cssVar.ts @@ -0,0 +1,21 @@ +/** + * Reads a CSS custom property from the root element and returns a valid colour + * string suitable for Plotly (which does not resolve CSS var() references). + * + * The project stores HSL components without hsl() — e.g. "--primary: 252 87% 67%". + * This converts them to "hsl(252, 87%, 67%)" or "hsla(..., alpha)" for Plotly. + */ +export function cssVar(name: string, alpha?: number): string { + const raw = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + + const parts = raw.split(/\s+/); + if (parts.length === 3) { + return alpha !== undefined + ? `hsla(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})` + : `hsl(${parts[0]}, ${parts[1]}, ${parts[2]})`; + } + + return raw; +} From afb5e99bb2c5912471da24ef7f5afecfa5d95402 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 23 Apr 2026 21:40:02 +0000 Subject: [PATCH 3/3] Add recurring transaction detection, subscriptions page, and UK tax reporting - Recurring service: auto-detects direct debits/subscriptions from CSV imports using frequency analysis; manual toggle in transaction detail drawer - Subscriptions page (/subscriptions): groups recurring payments with monthly cost equivalents, next-payment badges, and re-scan trigger - UK Tax page (/tax): payslips/P60 entry, income tax + NI + CGT + dividend tax calculations, configurable rate tables per tax year (pre-seeded 2024/25 and 2025/26), editable in-app so Budget changes need no rebuild - Migration 0006: tax_rate_configs, tax_profiles, payslips, manual_cgt_disposals with RLS; seeds 2025/2026 rate configs for existing users - Chart tooltip fix: all Recharts tooltips now use TOOLTIP_STYLE constant so they render correctly across all dark/light themes Co-Authored-By: Claude Sonnet 4.6 --- README.md | 22 +- RECURRING_TRANSACTIONS_PLAN.md | 277 +++++++ TAX_FEATURE_PLAN.md | 415 ++++++++++ .../alembic/versions/0006_add_tax_tables.py | 186 +++++ backend/app/api/router.py | 4 +- backend/app/api/v1/subscriptions.py | 123 +++ backend/app/api/v1/tax.py | 293 +++++++ backend/app/api/v1/transactions.py | 12 + backend/app/db/models/__init__.py | 2 + backend/app/db/models/tax.py | 74 ++ backend/app/schemas/tax.py | 215 +++++ backend/app/schemas/transaction.py | 3 + backend/app/services/auth_service.py | 4 + backend/app/services/recurring_service.py | 194 +++++ backend/app/services/tax_calculations.py | 301 +++++++ backend/app/services/tax_service.py | 744 ++++++++++++++++++ backend/app/services/transaction_service.py | 7 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 9 + backend/tests/test_tax_calculations.py | 311 ++++++++ backend/tests/test_tax_schemas.py | 262 ++++++ frontend/eslint.config.js | 30 + frontend/package.json | 2 +- frontend/src/App.tsx | 4 + frontend/src/api/subscriptions.ts | 28 + frontend/src/api/tax.ts | 309 ++++++++ frontend/src/api/transactions.ts | 3 + frontend/src/components/layout/MobileNav.tsx | 20 +- frontend/src/components/layout/Sidebar.tsx | 4 + frontend/src/pages/dashboard/Dashboard.tsx | 20 +- .../src/pages/investments/PortfolioCharts.tsx | 28 +- frontend/src/pages/reports/ReportsPage.tsx | 30 +- .../pages/subscriptions/SubscriptionsPage.tsx | 269 +++++++ frontend/src/pages/tax/CGTSection.tsx | 245 ++++++ frontend/src/pages/tax/DividendSection.tsx | 101 +++ .../src/pages/tax/ManualDisposalFormModal.tsx | 112 +++ .../src/pages/tax/OverallLiabilityCard.tsx | 77 ++ frontend/src/pages/tax/P60Modal.tsx | 115 +++ frontend/src/pages/tax/PayslipFormModal.tsx | 163 ++++ frontend/src/pages/tax/PayslipTable.tsx | 233 ++++++ frontend/src/pages/tax/RateConfigModal.tsx | 440 +++++++++++ frontend/src/pages/tax/TaxNISummaryCard.tsx | 99 +++ frontend/src/pages/tax/TaxPage.tsx | 234 ++++++ frontend/src/pages/tax/TaxProfileCard.tsx | 172 ++++ frontend/src/pages/tax/TaxYearSelector.tsx | 31 + .../transactions/TransactionDetailDrawer.tsx | 41 +- .../pages/transactions/TransactionList.tsx | 7 +- frontend/tailwind.config.ts | 2 +- 48 files changed, 6238 insertions(+), 39 deletions(-) create mode 100644 RECURRING_TRANSACTIONS_PLAN.md create mode 100644 TAX_FEATURE_PLAN.md create mode 100644 backend/alembic/versions/0006_add_tax_tables.py create mode 100644 backend/app/api/v1/subscriptions.py create mode 100644 backend/app/api/v1/tax.py create mode 100644 backend/app/db/models/tax.py create mode 100644 backend/app/schemas/tax.py create mode 100644 backend/app/services/recurring_service.py create mode 100644 backend/app/services/tax_calculations.py create mode 100644 backend/app/services/tax_service.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_tax_calculations.py create mode 100644 backend/tests/test_tax_schemas.py create mode 100644 frontend/eslint.config.js create mode 100644 frontend/src/api/subscriptions.ts create mode 100644 frontend/src/api/tax.ts create mode 100644 frontend/src/pages/subscriptions/SubscriptionsPage.tsx create mode 100644 frontend/src/pages/tax/CGTSection.tsx create mode 100644 frontend/src/pages/tax/DividendSection.tsx create mode 100644 frontend/src/pages/tax/ManualDisposalFormModal.tsx create mode 100644 frontend/src/pages/tax/OverallLiabilityCard.tsx create mode 100644 frontend/src/pages/tax/P60Modal.tsx create mode 100644 frontend/src/pages/tax/PayslipFormModal.tsx create mode 100644 frontend/src/pages/tax/PayslipTable.tsx create mode 100644 frontend/src/pages/tax/RateConfigModal.tsx create mode 100644 frontend/src/pages/tax/TaxNISummaryCard.tsx create mode 100644 frontend/src/pages/tax/TaxPage.tsx create mode 100644 frontend/src/pages/tax/TaxProfileCard.tsx create mode 100644 frontend/src/pages/tax/TaxYearSelector.tsx diff --git a/README.md b/README.md index 25f4613..4fb929b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w - Multiple account types: Checking, Savings, Cash ISA, Stocks & Shares ISA, Credit Card, Investment, Pension, Crypto Wallet, Loan, Mortgage, and more - Full transaction history with categories, tags, merchant tracking, and notes - Transfer detection between accounts -- Recurring transaction rules (rrule) +- **Recurring transaction detection** — CSV imports are automatically scanned for recurring payments (direct debits, subscriptions, standing orders) using frequency analysis; manually override any transaction - Receipt and document attachments on transactions (JPEG, PNG, WebP, PDF — up to 10 MB each) - **AI receipt scanning** — photograph a receipt to auto-extract merchant, amount, date, and description into a new transaction; receipt is automatically attached - CSV import with **auto-detection** for 10 UK bank formats: Monzo, Starling, Revolut, Barclays, Lloyds, NatWest, HSBC, Santander, Nationwide, and generic fallback @@ -36,13 +36,31 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w - Portfolio visualisations: allocation donut by holding, allocation donut by asset type, cost basis vs current value bar chart, return % per holding bar chart - Investment account balances include holding market values in net worth, balance sheet, and accounts list — supports both simple (holdings only) and full cash-flow tracking workflows -### Reports +### Subscriptions +- Automatic grouping of recurring transactions into a subscriptions dashboard +- Monthly cost equivalent for each subscription (normalised across weekly/fortnightly/monthly/quarterly/yearly frequencies) +- Next payment date badges with overdue/upcoming highlighting +- Re-scan button to re-run detection after new imports +- Mark any transaction as recurring (or not) from the transaction detail view + ### Categories - 45 built-in system categories across income, expense, and transfer types - Create custom categories with a name, type, and colour - Rename and recolour existing custom categories - Managed in **Settings → Categories** +### UK Tax Reporting +- Dedicated Tax page with per tax-year reports (tax_year=2026 = 2025/26) +- Enter monthly payslips or a single P60 annual figure +- Supports all HMRC tax codes: standard (1257L), BR, D0, D1, NT, K-codes, 0T, W1/M1 +- Calculates income tax liability with personal allowance taper above £100,000 +- NI Class 1 employee contributions +- Capital gains tax: auto-detected from investment disposals + manual entry for property/other assets +- Dividend tax: auto-detected from investment dividend transactions +- Shows liability vs. withheld (PAYE) and net owed/overpaid +- **Configurable rate tables** — edit tax bands in-app so Budget changes don't require a rebuild; pre-seeded for 2024/25 and 2025/26 (18%/24% CGT post Oct 2024 Budget) +- All figures are estimates for informational purposes only + ### Reports Seven report views: 1. Net Worth over time (area chart with time slider) diff --git a/RECURRING_TRANSACTIONS_PLAN.md b/RECURRING_TRANSACTIONS_PLAN.md new file mode 100644 index 0000000..d0c6385 --- /dev/null +++ b/RECURRING_TRANSACTIONS_PLAN.md @@ -0,0 +1,277 @@ +# Recurring Transactions — Implementation Plan + +## Goal + +Automatically detect recurring transactions (direct debits, subscriptions, standing orders) from imported and existing transaction history. Tag them, predict next payment dates, and surface them in a dedicated Subscriptions page. All auto-detected values are user-correctable. + +--- + +## Scope + +- Fixed-amount recurring detection only (variable-amount bills excluded — too unreliable) +- Detection runs automatically after every CSV import and on manual trigger +- Minimum 2 occurrences to flag as recurring +- Supported frequencies: weekly, fortnightly, monthly, quarterly, yearly +- User can manually mark or unmark any transaction as recurring +- New Subscriptions page in nav + recurring indicator in transaction list + +--- + +## Detection Algorithm + +### Description normalisation + +UK bank CSV exports embed reference numbers, dates, and prefixes in descriptions that differ per occurrence but refer to the same payee. Normalise before grouping: + +1. Lowercase the full string +2. Strip known UK bank prefixes: `direct debit`, `dd `, `so `, `standing order`, `bacs`, `faster payment` +3. Strip trailing reference numbers: sequences of 6+ digits at end of string +4. Strip embedded date patterns (e.g. `15apr`, `15/04`) +5. Collapse multiple spaces, trim + +Examples: +``` +"DIRECT DEBIT NETFLIX 00123456" → "netflix" +"DD SPOTIFY AB 987654" → "spotify ab" +"NETFLIX.COM" → "netflix.com" +"COUNCIL TAX REF 20240415" → "council tax ref" +``` + +### Grouping and interval analysis + +Group all non-deleted transactions for a user by `(normalised_description, exact_amount)`. + +For each group with 2+ transactions: +1. Sort by date ascending +2. Calculate day-intervals between consecutive occurrences +3. Compute average interval +4. Classify against frequency table: + +| Frequency | Avg days range | Per-interval tolerance | +|---|---|---| +| Weekly | 6–8 | ±2 days | +| Fortnightly | 13–15 | ±3 days | +| Monthly | 26–35 | ±5 days | +| Quarterly | 85–95 | ±10 days | +| Yearly | 355–375 | ±15 days | + +5. Check all individual intervals fall within tolerance of the average +6. If matched: tag all transactions in the group + +### Confidence score + +`confidence = 1 - (std_dev_of_intervals / expected_interval)` + +Capped at 1.0. Stored in `recurring_rule` for potential future use (e.g. sorting by confidence). + +### Next expected date + +- **Weekly/fortnightly**: last_date + N days +- **Monthly**: last_date + 1 month (using `dateutil.relativedelta` — handles month-end correctly) +- **Quarterly**: last_date + 3 months +- **Yearly**: last_date + 1 year + +If next_expected is in the past (missed payment or detection ran late), advance by one frequency period until it's in the future. + +--- + +## Data Model + +No new tables required. Uses existing columns on `transactions`: + +``` +is_recurring: bool — already exists +recurring_rule: JSONB — already exists, was never populated +``` + +### `recurring_rule` JSONB shape + +```json +{ + "frequency": "monthly", + "typical_amount": -10.99, + "typical_day": 15, + "next_expected": "2026-05-15", + "confidence": 0.94, + "detected_at": "2026-04-23T10:00:00Z", + "manually_set": false +} +``` + +`manually_set: true` when the user explicitly toggled it — detection will not overwrite manually-set entries on subsequent re-scans. + +--- + +## Backend + +### Service: `backend/app/services/recurring_service.py` (new) + +```python +def normalise_description(raw: str) -> str + # Strips prefixes, refs, dates, lowercases + +def classify_frequency(avg_days: float) -> str | None + # Returns "weekly" | "fortnightly" | "monthly" | "quarterly" | "yearly" | None + +def next_expected_date(last_date: date, frequency: str) -> date + # Advances last_date by one frequency period; repeats until future date + +async def detect_recurring(db: AsyncSession, user_id: uuid.UUID) -> dict + # Loads all transactions, runs grouping + interval analysis + # Updates is_recurring and recurring_rule on matched transactions + # Skips transactions where recurring_rule.manually_set == true + # Returns {"newly_tagged": int, "total_recurring": int} +``` + +### Changes to `transaction_service.py` + +- `import_csv()`: call `detect_recurring(db, user_id)` after flush, before return +- `_to_response()`: ensure `recurring_rule` is included in the response dict (it currently isn't) + +### New endpoint: `POST /transactions/detect-recurring` + +Manual trigger. Calls `detect_recurring()` and returns the counts. Rate-limited — no need to spam it. + +### Changes to `GET /transactions` (existing) + +No changes needed. `is_recurring` filter already works. + +### New endpoint: `GET /subscriptions` + +Returns a grouped summary for the Subscriptions page: + +```json +{ + "total_monthly_equivalent": 87.43, + "currency": "GBP", + "subscriptions": [ + { + "name": "Netflix", + "amount": -10.99, + "frequency": "monthly", + "next_expected": "2026-05-03", + "last_paid": "2026-04-03", + "account_name": "Monzo", + "account_id": "...", + "transaction_ids": ["...", "..."], + "confidence": 0.98, + "manually_set": false + } + ] +} +``` + +Monthly equivalent conversion: +- Weekly × 52 / 12 +- Fortnightly × 26 / 12 +- Monthly × 1 +- Quarterly / 3 +- Yearly / 12 + +### Changes to `PUT /transactions/{id}` (existing) + +Already accepts `is_recurring`. Extend to also accept `recurring_rule` patch so the frontend can write `manually_set: true` when the user toggles. + +--- + +## Frontend + +### Transaction list — recurring indicator + +In `TransactionList.tsx`, next to the existing paperclip icon for attachments, add a `↻` (`RefreshCw`) icon for transactions where `is_recurring === true`. Small, muted, same style as the paperclip. + +### Transaction detail drawer — manual toggle + +In `TransactionDetailDrawer.tsx`, add a toggle in the detail panel: + +``` +[ ↻ ] Mark as recurring [toggle] +``` + +When toggled on manually: sets `is_recurring=true`, writes `recurring_rule: { manually_set: true, frequency: null, ... }` — opens a small inline form to let the user pick the frequency and confirm the amount. When toggled off: sets `is_recurring=false`, clears `recurring_rule`. + +### New page: `frontend/src/pages/subscriptions/SubscriptionsPage.tsx` + +#### Header + +``` +Subscriptions & Standing Orders +Estimated monthly spend: £87.43 [ Re-scan ] +``` + +Re-scan button calls `POST /transactions/detect-recurring`, invalidates the subscriptions query. + +#### Subscription cards / list + +Each row: +``` +[ icon ] Netflix £10.99 / month + Monzo · last paid 3 Apr Next: 3 May [3 days] [ ··· ] +``` + +Colour coding on the "Next" badge: +- Red: overdue (next_expected in the past) +- Amber: within 7 days +- Muted: more than 7 days away + +The `···` menu: "Mark as not recurring" (unsets it with `manually_set: true`). + +#### Sort options + +- Next payment (default) +- Amount (high to low) +- Name (A–Z) + +#### Empty state + +If no recurring transactions detected yet: prompt to import a CSV or use Re-scan. + +### Nav addition + +Add `Subscriptions` to the sidebar and mobile nav, between Transactions and Reports. Icon: `RefreshCw` or `Repeat`. + +### API client: `frontend/src/api/subscriptions.ts` (new) + +```typescript +export interface Subscription { ... } +export interface SubscriptionsSummary { ... } + +export const getSubscriptions = (): Promise => ... +export const triggerDetection = (): Promise<{ newly_tagged: number; total_recurring: number }> => ... +``` + +--- + +## Implementation Order + +1. **`recurring_service.py`** — detection logic, fully unit-testable without DB +2. **Wire into `import_csv()`** — auto-detect after every import +3. **`POST /transactions/detect-recurring`** — manual trigger endpoint +4. **`GET /subscriptions`** — grouped summary endpoint +5. **Update `_to_response()`** — include `recurring_rule` in transaction response +6. **`SubscriptionsPage.tsx` + `api/subscriptions.ts`** — main UI +7. **Transaction list `↻` indicator** — small icon addition +8. **Transaction detail drawer toggle** — manual mark/unmark + +--- + +## Testing Checkpoints + +- Normalisation: verify `"DIRECT DEBIT NETFLIX 00123456"` → `"netflix"` +- Monthly detection: 3 transactions, same amount, ~30 days apart → detected +- Below threshold: 1 occurrence → not detected +- Tolerance: transactions on 1st, 31st, 2nd of following months → monthly detected (within ±5 days) +- `manually_set: true` entries are not overwritten on re-scan +- Price change: Netflix at £10.99 (3 entries) + £11.99 (1 entry) → old entries stay tagged, new one not auto-tagged +- Monthly equivalent: yearly £120 subscription → shows £10.00/month + +--- + +## Notes & Decisions + +- **No new DB migration required** — `is_recurring` and `recurring_rule` columns already exist from the initial schema. +- **Detection is idempotent** — running it twice produces the same result. Safe to call on every import. +- **`manually_set` flag protects user corrections** — if a user untags something, a subsequent CSV import will not re-tag it. +- **`recurring_rule` not currently returned by `_to_response()`** — this must be added before the frontend can read frequency/next_expected. +- **Variable-amount recurrings** (energy, credit card payments) are out of scope. User can manually tag these if desired. +- **2-occurrence minimum** is configurable as a constant in `recurring_service.py` (`MIN_OCCURRENCES = 2`). diff --git a/TAX_FEATURE_PLAN.md b/TAX_FEATURE_PLAN.md new file mode 100644 index 0000000..555a4c4 --- /dev/null +++ b/TAX_FEATURE_PLAN.md @@ -0,0 +1,415 @@ +# Tax Feature — Implementation Plan + +## Goal + +Add a UK tax reporting section to MyMidas that lets a PAYE employee enter their tax code and payslip/P60 data, then automatically calculates their income tax, NI, capital gains, and dividend tax liabilities for a selected tax year — showing what's been withheld, what's owed, and a full breakdown report. + +--- + +## Scope (Phase 1) + +- UK only, Rest of England / Wales / Northern Ireland tax bands (not Scotland) +- PAYE employment — single employer per tax year (schema supports more) +- Income data via monthly payslips or annual P60 +- Capital gains auto-calculated from existing investment disposals + manual entry option +- Dividend tax auto-calculated from existing investment dividend transactions +- No student loan, no self-employment income, no pension contributions modelling +- Tax lives as a **dedicated sidebar page** (`/tax`) — not a tab inside Reports + +Out of scope for Phase 1, possible Phase 2: +- Scotland rates +- Multiple employments +- Self-assessment / self-employment income +- Pension contribution relief +- Additional countries + +--- + +## Database Schema + +### Table: `tax_rate_configs` + +Stores the tax rates for each year. Pre-populated by migration; editable in-app via the Tax settings panel so future Budget changes don't require a code deployment or container rebuild. + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `user_id` | UUID FK → users | RLS keyed — each user owns their own copy | +| `tax_year` | INTEGER | e.g. `2025` = year ending 5 April 2025 | +| `rate_type` | VARCHAR(30) | `income_tax` / `ni` / `cgt` / `dividend` | +| `config` | JSONB | Rate bands / thresholds for that type (see format below) | +| `updated_at` | TIMESTAMPTZ | | + +Unique constraint: `(user_id, tax_year, rate_type)`. + +Migration pre-populates rows for **2025** and **2026** for every new user (see seed data below). The service loads rates from this table (with a short in-process TTL cache) rather than from hardcoded Python constants. + +#### Config JSONB format per rate_type + +**income_tax** and **ni**: +```json +{ + "bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12571, "to": 50270, "rate": 0.20}, + {"from": 50271, "to": 125140, "rate": 0.40}, + {"from": 125141, "to": null, "rate": 0.45} + ] +} +``` + +**cgt**: +```json +{ + "exempt": 3000, + "basic_rate": 0.18, + "higher_rate": 0.24 +} +``` + +**dividend**: +```json +{ + "allowance": 500, + "basic_rate": 0.0875, + "higher_rate": 0.3375, + "additional_rate": 0.3935 +} +``` + +#### Seed data (2025 and 2026) + +2025 = year ending 5 April 2025 (2024/25). CGT rates reflect the October 2024 Budget change (18%/24% effective 30 Oct 2024 — applied to the full year for simplicity; note in disclaimer). + +2026 = year ending 5 April 2026 (2025/26). Income tax thresholds remain frozen; NI, CGT, and dividend rates unchanged from 2025. + +```python +SEED_RATE_CONFIGS = { + 2025: { + "income_tax": {"bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12571, "to": 50270, "rate": 0.20}, + {"from": 50271, "to": 125140, "rate": 0.40}, + {"from": 125141, "to": None, "rate": 0.45}, + ]}, + "ni": {"bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12571, "to": 50270, "rate": 0.08}, + {"from": 50271, "to": None, "rate": 0.02}, + ]}, + "cgt": {"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24}, + "dividend": {"allowance": 500, "basic_rate": 0.0875, + "higher_rate": 0.3375, "additional_rate": 0.3935}, + }, + 2026: { + "income_tax": {"bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12571, "to": 50270, "rate": 0.20}, + {"from": 50271, "to": 125140, "rate": 0.40}, + {"from": 125141, "to": None, "rate": 0.45}, + ]}, + "ni": {"bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12571, "to": 50270, "rate": 0.08}, + {"from": 50271, "to": None, "rate": 0.02}, + ]}, + "cgt": {"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24}, + "dividend": {"allowance": 500, "basic_rate": 0.0875, + "higher_rate": 0.3375, "additional_rate": 0.3935}, + }, +} +``` + +When a new user registers, the `tax_rate_configs` rows for 2025 and 2026 are inserted automatically (same place other user-default data is seeded). Adding a future year (e.g. 2027) requires inserting new rows — a small migration — but never a code change. + +### Table: `tax_profiles` + +One row per tax year. Designed to support multiple employments in future. + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `user_id` | UUID FK → users | RLS keyed | +| `tax_year` | INTEGER | e.g. `2025` = year ending 5 April 2025 | +| `employer_name_enc` | BYTEA | AES-256-GCM encrypted | +| `tax_code` | VARCHAR(20) | e.g. `1257L`, `BR`, `D0`, `K100` | +| `is_cumulative` | BOOLEAN | `true` = cumulative basis, `false` = W1/M1 | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | | + +Unique constraint: `(user_id, tax_year)`. + +### Table: `payslips` + +Monthly payslip entries, or a single P60 annual entry. + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `user_id` | UUID FK → users | RLS keyed | +| `tax_profile_id` | UUID FK → tax_profiles | | +| `period_month` | SMALLINT | 1–12; `NULL` if `is_p60 = true` | +| `period_year` | SMALLINT | Calendar year of the payslip | +| `gross_pay` | NUMERIC(14,2) | | +| `income_tax_withheld` | NUMERIC(14,2) | | +| `ni_withheld` | NUMERIC(14,2) | | +| `net_pay` | NUMERIC(14,2) | | +| `is_p60` | BOOLEAN | `true` = this is the annual P60 figure | +| `notes_enc` | BYTEA | AES-256-GCM encrypted, optional | +| `created_at` | TIMESTAMPTZ | | + +When a P60 is entered for a tax year, all existing individual payslips for that profile are deleted and replaced by the single P60 row (confirmed via a warning dialog in the UI). + +### Table: `manual_cgt_disposals` + +For assets not tracked in the investments section (e.g. property, share schemes, other). + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `user_id` | UUID FK → users | RLS keyed | +| `tax_year` | INTEGER | | +| `disposal_date` | DATE | | +| `asset_description_enc` | BYTEA | AES-256-GCM encrypted | +| `proceeds` | NUMERIC(14,2) | | +| `cost_basis` | NUMERIC(14,2) | | +| `notes_enc` | BYTEA | AES-256-GCM encrypted | +| `created_at` | TIMESTAMPTZ | | + +`gain_loss` is **not stored** — computed in the service as `proceeds − cost_basis`. + +--- + +## Tax Calculation Engine + +### File: `backend/app/services/tax_service.py` + +Rates are loaded from `tax_rate_configs` (DB), not hardcoded. The service caches the loaded config per `(user_id, tax_year)` for the lifetime of the request. + +#### Tax year helper + +UK tax year runs 6 April → 5 April. Convention: `tax_year=2025` means the year **ending** 5 April 2025 (the 2024/25 tax year). + +```python +def tax_year_for_date(d: date) -> int: + """Return the tax_year int for a given date. tax_year=N means 6 Apr (N-1) → 5 Apr N.""" + if (d.month, d.day) >= (4, 6): + return d.year + 1 + return d.year +``` + +#### Core functions + +All calculation functions receive a `rates: dict` argument (the loaded config for that year/type) rather than reading from constants. + +```python +async def load_rates(db, user_id, tax_year) -> dict: + # Returns {"income_tax": {...}, "ni": {...}, "cgt": {...}, "dividend": {...}} + # Loads from tax_rate_configs table; raises 404 if year not configured + +def parse_tax_code(code: str) -> dict: + # Returns: {"allowance": int, "rate_override": float | None, "k_code": bool} + +def calculate_income_tax(gross_income: Decimal, tax_code: str, rates: dict) -> dict: + # Returns: {"personal_allowance": Decimal, "taxable_income": Decimal, + # "liability": Decimal, "band_breakdown": [...]} + +def calculate_ni(gross_income: Decimal, rates: dict) -> dict: + # Returns: {"liability": Decimal, "band_breakdown": [...]} + +def calculate_cgt(gains: Decimal, gross_income: Decimal, rates: dict) -> dict: + # Determines basic vs higher rate from remaining basic rate band + # Returns: {"gross_gain": Decimal, "exempt": Decimal, "taxable_gain": Decimal, + # "rate_applied": float, "liability": Decimal} + +def calculate_dividend_tax(dividends: Decimal, gross_income: Decimal, rates: dict) -> dict: + # Returns: {"gross_dividends": Decimal, "allowance": Decimal, + # "taxable_dividends": Decimal, "liability": Decimal, "rate_applied": float} + +async def build_tax_report(db, user_id, tax_year) -> dict: + # Loads rates, pulls payslip totals, investment disposals, dividend transactions + # Returns the full report payload +``` + +#### Tax code parser + +| Code pattern | Behaviour | +|---|---| +| `1257L`, `1257M`, `1257N` | allowance = digits × 10 | +| `BR` | allowance = 0, flat 20% on all income | +| `D0` | flat 40% on all income | +| `D1` | flat 45% on all income | +| `NT` | no tax | +| `K100` | negative allowance: taxable income += digits × 10 | +| `0T` | allowance = 0, standard bands apply | +| `W1`/`M1` suffix | non-cumulative (informational only for Phase 1) | + +Personal allowance taper: reduce by £1 for every £2 of income above £100,000, down to zero at £125,140. + +--- + +## Backend API + +### File: `backend/app/api/v1/tax.py` + +All routes prefixed `/tax`. + +| Method | Path | Description | +|---|---|---| +| GET | `/tax/rate-configs` | List configured tax years for this user | +| GET | `/tax/rate-configs/{tax_year}` | Get full rate config for a year | +| PUT | `/tax/rate-configs/{tax_year}` | Create or update rate config for a year | +| GET | `/tax/profile/{tax_year}` | Get profile for a tax year (404 if none) | +| PUT | `/tax/profile/{tax_year}` | Create or update profile (tax code, employer name) | +| GET | `/tax/payslips/{tax_year}` | List payslips for a tax year | +| POST | `/tax/payslips/{tax_year}` | Add a payslip | +| PUT | `/tax/payslips/{id}` | Edit a payslip | +| DELETE | `/tax/payslips/{id}` | Delete a payslip | +| POST | `/tax/payslips/{tax_year}/p60` | Enter P60 — replaces all individual payslips | +| GET | `/tax/cgt-disposals/{tax_year}` | List manual CGT disposals | +| POST | `/tax/cgt-disposals/{tax_year}` | Add a manual disposal | +| PUT | `/tax/cgt-disposals/{id}` | Edit | +| DELETE | `/tax/cgt-disposals/{id}` | Delete | +| GET | `/tax/report/{tax_year}` | Full computed tax report for a year | + +### Pydantic schemas: `backend/app/schemas/tax.py` + +- `TaxRateConfigUpdate` / `TaxRateConfigResponse` +- `TaxProfileCreate` / `TaxProfileResponse` +- `PayslipCreate` / `PayslipResponse` +- `P60Entry` (gross_pay, income_tax_withheld, ni_withheld, net_pay) +- `ManualDisposalCreate` / `ManualDisposalResponse` +- `TaxReportResponse` + +--- + +## Migration + +New Alembic migration: `add_tax_tables` + +- Creates `tax_rate_configs`, `tax_profiles`, `payslips`, `manual_cgt_disposals` +- Adds RLS policies (same pattern as other tables: `app.current_user_id`) +- Encrypted columns stored as `_enc bytea`: `employer_name_enc`, `notes_enc`, `asset_description_enc` +- Seeds `tax_rate_configs` rows for 2025 and 2026 for all existing users inside the migration (so existing accounts get rates without re-registering) + +--- + +## Frontend + +### Nav changes + +Add `{ href: "/tax", icon: Receipt, label: "Tax" }` to both: +- `frontend/src/components/layout/Sidebar.tsx` — between Reports and Predictions +- `frontend/src/components/layout/MobileNav.tsx` — same position +- `frontend/src/App.tsx` — add `/tax` route pointing to `TaxPage` + +### API client: `frontend/src/api/tax.ts` + +Typed functions for all endpoints. Interfaces for: +- `TaxRateConfig`, `TaxRateConfigUpdate` +- `TaxProfile`, `TaxProfileCreate` +- `Payslip`, `PayslipCreate`, `P60Entry` +- `ManualDisposal`, `ManualDisposalCreate` +- `TaxReport` + +### Page: `frontend/src/pages/tax/TaxPage.tsx` + +Top-level page at `/tax`. Contains the full tax UI. + +#### Layout + +``` +[ Tax Year selector: 2024/25 | 2025/26 | ... ] [ Rate Config button (edit rates for year) ] + +[ Tax Profile card ] + Employer: Acme Ltd Tax Code: 1257L [ Edit ] + +[ Income & PAYE section ] + Payslip table (month | gross | tax withheld | NI withheld | net) + [ + Add Payslip ] [ Enter P60 ] + Summary row: totals + +[ Tax & NI Summary card ] + Gross income £xx,xxx + Personal allowance £12,570 + Taxable income £xx,xxx + ───────────────────────────────────────── + Income tax liability £x,xxx Withheld £x,xxx [ Owed / Overpaid ] + NI liability £x,xxx Withheld £x,xxx [ Owed / Overpaid ] + +[ Capital Gains section ] + Auto-detected disposals from investments (read-only table) + Manual disposals table [ + Add Disposal ] + Summary: total gains | exempt | taxable | estimated CGT + +[ Dividends section ] + Auto-detected from investment dividend transactions (read-only) + Summary: total dividends | allowance | taxable | estimated dividend tax + +[ Overall Liability card ] + ┌──────────────────────────────────────────┐ + │ Total liability £x,xxx │ + │ Total withheld £x,xxx │ + │ ─────────────────────────────────────── │ + │ Net owed to HMRC / Overpaid £x,xxx │ + └──────────────────────────────────────────┘ + +[ Disclaimer: estimates only — not financial advice ] +``` + +#### Components to build (all in `frontend/src/pages/tax/`) + +- `TaxPage.tsx` — top-level, holds selected tax year state +- `TaxYearSelector.tsx` — dropdown of configured years +- `RateConfigModal.tsx` — shows/edits the JSONB rate bands for the selected year (table of bands, editable inputs) +- `TaxProfileCard.tsx` — shows/edits tax code and employer name +- `PayslipTable.tsx` — list, add, edit, delete; "Enter P60" button with confirmation dialog +- `PayslipFormModal.tsx` — single payslip month form +- `P60Modal.tsx` — four-field form (gross, tax withheld, NI withheld, net) with warning dialog +- `TaxNISummaryCard.tsx` — computed liability vs withheld, owed/overpaid highlighted +- `CGTSection.tsx` — auto + manual disposal tables, summary +- `ManualDisposalFormModal.tsx` +- `DividendSection.tsx` — auto-pulled, summary only +- `OverallLiabilityCard.tsx` — final totals + +--- + +## Implementation Order + +1. **Migration** — create the four tables with RLS policies; seed 2025/2026 rate configs for existing users +2. **Tax calculation engine** (`tax_service.py`) — pure functions taking `rates: dict`, unit-testable without DB +3. **Backend models** (`db/models/tax.py`) — SQLAlchemy mapped classes +4. **Pydantic schemas** (`schemas/tax.py`) +5. **Service layer** — DB queries for CRUD + `load_rates()` + `build_tax_report()` +6. **API endpoints** (`api/v1/tax.py`) + register in `router.py` +7. **Frontend API client** (`api/tax.ts`) +8. **Nav wiring** — add Tax to Sidebar, MobileNav, App.tsx routes +9. **Tax page UI** — build components top-to-bottom following the layout above +10. **Rate config UI** — `RateConfigModal` so rates can be edited without touching code +11. **End-to-end test** — enter a full year of payslips, verify liability matches HMRC calculator + +--- + +## Testing Checkpoints + +- Tax code parser: `1257L`, `BR`, `D0`, `K100`, `0T`, `1257M` +- Income tax: verify bands at £12,570 / £50,270 / £100,000 (taper) / £125,140 +- NI: verify thresholds +- CGT: basic rate (18%) vs higher rate (24%) taxpayer +- P60 replacement: individual payslips deleted before P60 insert +- Investment disposal auto-detection: verify `tax_year_for_date` boundary (6 Apr) +- Rate config: edit a band value, confirm report recalculates using new value + +--- + +## Notes & Decisions + +- **Tax year convention**: `tax_year = 2025` = year ending 5 April 2025 = 2024/25. Always display as "2024/25" in the UI. +- **Configurable rates (Option A)**: Rates live in `tax_rate_configs` DB table. Pre-populated for 2025 and 2026. When a new Budget changes rates, the user edits them in-app via `RateConfigModal` — no code change or rebuild needed. Adding a brand new tax year requires a small migration to insert new rows, but that's still no code change in the calculation logic. +- **Encrypted fields**: `employer_name_enc`, `notes_enc`, `asset_description_enc` — all PII stored as `_enc bytea` using `encrypt_field`/`decrypt_field` from `core/security.py`. +- **`gain_loss` not stored**: Computed in service as `proceeds − cost_basis`. Not a DB column. +- **CGT rates (post Oct 2024 Budget)**: 18% basic rate, 24% higher rate. For 2024/25 (tax_year=2025), the change was effective 30 Oct 2024 mid-year — the seeded rate uses 18%/24% for the full year with a disclaimer note in the UI. +- **CGT rate determination**: Requires knowing whether the user is basic or higher rate (remaining basic rate band after income). `build_tax_report()` computes income tax first, then passes the remaining band to `calculate_cgt()`. +- **Sidebar placement**: Tax sits between Reports and Predictions in both `Sidebar.tsx` and `MobileNav.tsx`. +- **Disclaimer**: Report UI must include a visible note that figures are estimates for informational purposes only and are not financial or tax advice. +- **Future expansion**: `tax_profiles` unique constraint is `(user_id, tax_year)` for now — relax to allow multiple rows per year when multi-employment is added. diff --git a/backend/alembic/versions/0006_add_tax_tables.py b/backend/alembic/versions/0006_add_tax_tables.py new file mode 100644 index 0000000..4645d16 --- /dev/null +++ b/backend/alembic/versions/0006_add_tax_tables.py @@ -0,0 +1,186 @@ +"""add tax tables + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-04-23 +""" +import uuid +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + +# --------------------------------------------------------------------------- +# Seed data for 2025 and 2026 +# --------------------------------------------------------------------------- + +_INCOME_TAX_BANDS_2025 = { + "bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12570, "to": 50270, "rate": 0.20}, + {"from": 50270, "to": 125140, "rate": 0.40}, + {"from": 125140, "to": None, "rate": 0.45}, + ] +} +_NI_BANDS_2025 = { + "bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12570, "to": 50270, "rate": 0.08}, + {"from": 50270, "to": None, "rate": 0.02}, + ] +} +_CGT_2025 = {"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24} +_DIVIDEND_2025 = { + "allowance": 500, + "basic_rate": 0.0875, + "higher_rate": 0.3375, + "additional_rate": 0.3935, +} + +# 2026 thresholds remain frozen; rates unchanged from 2025 +_SEED = { + 2025: { + "income_tax": _INCOME_TAX_BANDS_2025, + "ni": _NI_BANDS_2025, + "cgt": _CGT_2025, + "dividend": _DIVIDEND_2025, + }, + 2026: { + "income_tax": _INCOME_TAX_BANDS_2025, + "ni": _NI_BANDS_2025, + "cgt": _CGT_2025, + "dividend": _DIVIDEND_2025, + }, +} + + +def upgrade() -> None: + # ------------------------------------------------------------------ + # tax_rate_configs + # ------------------------------------------------------------------ + op.create_table( + "tax_rate_configs", + 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("tax_year", sa.Integer, nullable=False), + sa.Column("rate_type", sa.String(30), nullable=False), + sa.Column("config", postgresql.JSONB, nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_unique_constraint( + "uq_tax_rate_configs_user_year_type", + "tax_rate_configs", + ["user_id", "tax_year", "rate_type"], + ) + op.create_index("ix_tax_rate_configs_user_id", "tax_rate_configs", ["user_id"]) + + # ------------------------------------------------------------------ + # tax_profiles + # ------------------------------------------------------------------ + op.create_table( + "tax_profiles", + 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("tax_year", sa.Integer, nullable=False), + sa.Column("employer_name_enc", sa.LargeBinary, nullable=True), + sa.Column("tax_code", sa.String(20), nullable=False, server_default="1257L"), + sa.Column("is_cumulative", sa.Boolean, nullable=False, server_default="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_tax_profiles_user_year", + "tax_profiles", + ["user_id", "tax_year"], + ) + op.create_index("ix_tax_profiles_user_id", "tax_profiles", ["user_id"]) + + # ------------------------------------------------------------------ + # payslips + # ------------------------------------------------------------------ + op.create_table( + "payslips", + 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("tax_profile_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("tax_profiles.id", ondelete="CASCADE"), nullable=False), + sa.Column("period_month", sa.SmallInteger, nullable=True), + sa.Column("period_year", sa.SmallInteger, nullable=False), + sa.Column("gross_pay", sa.Numeric(14, 2), nullable=False), + sa.Column("income_tax_withheld", sa.Numeric(14, 2), nullable=False), + sa.Column("ni_withheld", sa.Numeric(14, 2), nullable=False), + sa.Column("net_pay", sa.Numeric(14, 2), nullable=False), + sa.Column("is_p60", sa.Boolean, nullable=False, server_default="false"), + sa.Column("notes_enc", sa.LargeBinary, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_payslips_user_id", "payslips", ["user_id"]) + op.create_index("ix_payslips_tax_profile_id", "payslips", ["tax_profile_id"]) + + # ------------------------------------------------------------------ + # manual_cgt_disposals + # ------------------------------------------------------------------ + op.create_table( + "manual_cgt_disposals", + 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("tax_year", sa.Integer, nullable=False), + sa.Column("disposal_date", sa.Date, nullable=False), + sa.Column("asset_description_enc", sa.LargeBinary, nullable=False), + sa.Column("proceeds", sa.Numeric(14, 2), nullable=False), + sa.Column("cost_basis", sa.Numeric(14, 2), nullable=False), + sa.Column("notes_enc", sa.LargeBinary, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_manual_cgt_disposals_user_id", "manual_cgt_disposals", ["user_id"]) + + # ------------------------------------------------------------------ + # RLS + # ------------------------------------------------------------------ + for table in ["tax_rate_configs", "tax_profiles", "payslips", "manual_cgt_disposals"]: + 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()) + """) + + # ------------------------------------------------------------------ + # Seed 2025 + 2026 rate configs for all existing users + # ------------------------------------------------------------------ + import json + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + + for tax_year, rate_types in _SEED.items(): + for rate_type, config in rate_types.items(): + op.execute(sa.text(""" + INSERT INTO tax_rate_configs (id, user_id, tax_year, rate_type, config, updated_at) + SELECT gen_random_uuid(), id, :tax_year, :rate_type, CAST(:config AS jsonb), CAST(:updated_at AS timestamptz) + FROM users + WHERE deleted_at IS NULL + ON CONFLICT (user_id, tax_year, rate_type) DO NOTHING + """).bindparams( + tax_year=tax_year, + rate_type=rate_type, + config=json.dumps(config), + updated_at=now, + )) + + +def downgrade() -> None: + for table in ["tax_rate_configs", "tax_profiles", "payslips", "manual_cgt_disposals"]: + 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("manual_cgt_disposals") + op.drop_table("payslips") + op.drop_table("tax_profiles") + op.drop_table("tax_rate_configs") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 85c5c1d..3ab6a71 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin, settings +from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin, settings, subscriptions, tax router = APIRouter() router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -14,3 +14,5 @@ router.include_router(investments.router) router.include_router(predictions.router) router.include_router(admin.router) router.include_router(settings.router) +router.include_router(subscriptions.router) +router.include_router(tax.router) diff --git a/backend/app/api/v1/subscriptions.py b/backend/app/api/v1/subscriptions.py new file mode 100644 index 0000000..153230d --- /dev/null +++ b/backend/app/api/v1/subscriptions.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from collections import defaultdict +from decimal import Decimal + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decrypt_field +from app.db.models.account import Account +from app.db.models.transaction import Transaction +from app.dependencies import get_current_user, get_db + +router = APIRouter(prefix="/subscriptions", tags=["subscriptions"]) + +_MONTHLY_FACTORS = { + "weekly": Decimal("52") / Decimal("12"), + "fortnightly": Decimal("26") / Decimal("12"), + "monthly": Decimal("1"), + "quarterly": Decimal("1") / Decimal("3"), + "yearly": Decimal("1") / Decimal("12"), +} + + +@router.get("") +async def get_subscriptions( + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +): + """Return all detected recurring transactions grouped as subscriptions.""" + + txn_result = await db.execute( + select(Transaction).where( + Transaction.user_id == user.id, + Transaction.is_recurring == True, + Transaction.deleted_at.is_(None), + ) + ) + transactions = txn_result.scalars().all() + + # Load accounts for name lookup + acc_result = await db.execute( + select(Account).where( + Account.user_id == user.id, + Account.deleted_at.is_(None), + ) + ) + account_map = {a.id: a for a in acc_result.scalars().all()} + + # Group by (normalised frequency+amount key from recurring_rule) + # Use (frequency, typical_amount, normalised_name) as the grouping key + # so manually-set entries with no rule still appear individually + from app.services.recurring_service import normalise_description + + # Group: key → list of transactions + groups: dict[str, list[Transaction]] = defaultdict(list) + for txn in transactions: + rule = txn.recurring_rule or {} + freq = rule.get("frequency", "unknown") + amt = rule.get("typical_amount", float(txn.amount)) + try: + desc = decrypt_field(txn.description_enc) or "" + except Exception: + desc = "" + norm = normalise_description(desc) + key = f"{norm}|{amt}|{freq}" + groups[key].append(txn) + + subscriptions = [] + total_monthly = Decimal("0") + + for key, txns in groups.items(): + # Use the transaction with the most recent date as the representative + txns_sorted = sorted(txns, key=lambda t: t.date, reverse=True) + latest = txns_sorted[0] + rule = latest.recurring_rule or {} + + freq = rule.get("frequency", "unknown") + amount = Decimal(str(rule.get("typical_amount", float(latest.amount)))) + next_expected = rule.get("next_expected") + last_paid = rule.get("last_paid") or str(latest.date) + confidence = rule.get("confidence", 1.0) + manually_set = rule.get("manually_set", False) + + try: + desc = decrypt_field(latest.description_enc) or "" + except Exception: + desc = "" + + account = account_map.get(latest.account_id) + try: + account_name = decrypt_field(account.name_enc) if account else None + except Exception: + account_name = None + + factor = _MONTHLY_FACTORS.get(freq, Decimal("1")) + monthly_equiv = abs(amount) * factor + total_monthly += monthly_equiv + + subscriptions.append({ + "name": desc, + "amount": float(amount), + "frequency": freq, + "next_expected": next_expected, + "last_paid": last_paid, + "account_id": str(latest.account_id), + "account_name": account_name, + "transaction_ids": [str(t.id) for t in txns], + "latest_transaction_id": str(latest.id), + "monthly_equivalent": float(monthly_equiv.quantize(Decimal("0.01"))), + "confidence": confidence, + "manually_set": manually_set, + }) + + # Sort by next_expected ascending (soonest first), nulls last + subscriptions.sort(key=lambda s: s["next_expected"] or "9999-99-99") + + return { + "total_monthly_equivalent": float(total_monthly.quantize(Decimal("0.01"))), + "currency": user.base_currency, + "subscriptions": subscriptions, + } diff --git a/backend/app/api/v1/tax.py b/backend/app/api/v1/tax.py new file mode 100644 index 0000000..d3b2c67 --- /dev/null +++ b/backend/app/api/v1/tax.py @@ -0,0 +1,293 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_current_user, get_db +from app.db.models.user import User +from app.schemas.tax import ( + ManualDisposalCreate, + ManualDisposalResponse, + ManualDisposalUpdate, + P60Entry, + PayslipCreate, + PayslipResponse, + PayslipUpdate, + TaxProfileCreate, + TaxProfileResponse, + TaxRateConfigResponse, + TaxRateConfigUpdate, + TaxReportResponse, +) +from app.services import tax_service + +router = APIRouter(tags=["tax"]) + + +# --------------------------------------------------------------------------- +# Rate configs +# --------------------------------------------------------------------------- + +@router.get("/tax/rate-configs", response_model=list[int]) +async def list_rate_config_years( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return await tax_service.list_configured_years(db, current_user.id) + + +@router.get("/tax/rate-configs/{tax_year}", response_model=TaxRateConfigResponse) +async def get_rate_config( + tax_year: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + try: + return await tax_service.get_rate_config(db, current_user.id, tax_year) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.put("/tax/rate-configs/{tax_year}", response_model=TaxRateConfigResponse) +async def upsert_rate_config( + tax_year: int, + data: TaxRateConfigUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + rates = {k: v for k, v in data.model_dump().items() if v is not None} + if not rates: + raise HTTPException(status_code=422, detail="At least one rate type must be provided") + result = await tax_service.upsert_rate_config(db, current_user.id, tax_year, rates) + await db.commit() + return result + + +# --------------------------------------------------------------------------- +# Tax profile +# --------------------------------------------------------------------------- + +@router.get("/tax/profile/{tax_year}", response_model=TaxProfileResponse) +async def get_tax_profile( + tax_year: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + profile = await tax_service.get_tax_profile(db, current_user.id, tax_year) + if profile is None: + raise HTTPException(status_code=404, detail="No tax profile for this year") + return tax_service._profile_to_response(profile) + + +@router.put("/tax/profile/{tax_year}", response_model=TaxProfileResponse) +async def upsert_tax_profile( + tax_year: int, + data: TaxProfileCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + profile = await tax_service.upsert_tax_profile( + db, + current_user.id, + tax_year, + tax_code=data.tax_code, + employer_name=data.employer_name, + is_cumulative=data.is_cumulative, + ) + await db.commit() + return tax_service._profile_to_response(profile) + + +# --------------------------------------------------------------------------- +# Payslips +# --------------------------------------------------------------------------- + +@router.get("/tax/payslips/{tax_year}", response_model=list[PayslipResponse]) +async def list_payslips( + tax_year: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + payslips = await tax_service.list_payslips(db, current_user.id, tax_year) + return [tax_service._payslip_to_response(p) for p in payslips] + + +@router.post("/tax/payslips/{tax_year}", response_model=PayslipResponse, status_code=status.HTTP_201_CREATED) +async def create_payslip( + tax_year: int, + data: PayslipCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + try: + payslip = await tax_service.create_payslip( + db, + current_user.id, + tax_year, + period_month=data.period_month, + period_year=data.period_year, + gross_pay=data.gross_pay, + income_tax_withheld=data.income_tax_withheld, + ni_withheld=data.ni_withheld, + net_pay=data.net_pay, + notes=data.notes, + ) + await db.commit() + return tax_service._payslip_to_response(payslip) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.put("/tax/payslips/{payslip_id}", response_model=PayslipResponse) +async def update_payslip( + payslip_id: uuid.UUID, + data: PayslipUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + try: + updates = {k: v for k, v in data.model_dump().items() if v is not None} + payslip = await tax_service.update_payslip(db, current_user.id, payslip_id, **updates) + await db.commit() + return tax_service._payslip_to_response(payslip) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.delete("/tax/payslips/{payslip_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_payslip( + payslip_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + try: + await tax_service.delete_payslip(db, current_user.id, payslip_id) + await db.commit() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.post("/tax/payslips/{tax_year}/p60", status_code=status.HTTP_204_NO_CONTENT) +async def enter_p60( + tax_year: int, + data: P60Entry, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + try: + await tax_service.replace_with_p60( + db, + current_user.id, + tax_year, + gross_pay=data.gross_pay, + income_tax_withheld=data.income_tax_withheld, + ni_withheld=data.ni_withheld, + net_pay=data.net_pay, + ) + await db.commit() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +# --------------------------------------------------------------------------- +# Manual CGT disposals +# --------------------------------------------------------------------------- + +@router.get("/tax/cgt-disposals/{tax_year}", response_model=list[ManualDisposalResponse]) +async def list_cgt_disposals( + tax_year: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + disposals = await tax_service.list_manual_disposals(db, current_user.id, tax_year) + return [tax_service._disposal_to_response(d) for d in disposals] + + +@router.post("/tax/cgt-disposals/{tax_year}", response_model=ManualDisposalResponse, status_code=status.HTTP_201_CREATED) +async def create_cgt_disposal( + tax_year: int, + data: ManualDisposalCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + disposal = await tax_service.create_manual_disposal( + db, + current_user.id, + tax_year, + disposal_date=data.disposal_date, + asset_description=data.asset_description, + proceeds=data.proceeds, + cost_basis=data.cost_basis, + notes=data.notes, + ) + await db.commit() + return tax_service._disposal_to_response(disposal) + + +@router.put("/tax/cgt-disposals/{disposal_id}", response_model=ManualDisposalResponse) +async def update_cgt_disposal( + disposal_id: uuid.UUID, + data: ManualDisposalUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + from sqlalchemy import select + from app.db.models.tax import ManualCGTDisposal + from app.core.security import decrypt_field + + result = await db.execute( + select(ManualCGTDisposal).where( + ManualCGTDisposal.id == disposal_id, + ManualCGTDisposal.user_id == current_user.id, + ) + ) + disposal = result.scalar_one_or_none() + if disposal is None: + raise HTTPException(status_code=404, detail="Disposal not found") + + current_desc = decrypt_field(disposal.asset_description_enc) if disposal.asset_description_enc else "" + + try: + updated = await tax_service.update_manual_disposal( + db, + current_user.id, + disposal_id, + disposal_date=data.disposal_date or disposal.disposal_date, + asset_description=data.asset_description or current_desc, + proceeds=data.proceeds if data.proceeds is not None else disposal.proceeds, + cost_basis=data.cost_basis if data.cost_basis is not None else disposal.cost_basis, + notes=data.notes, + ) + await db.commit() + return tax_service._disposal_to_response(updated) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.delete("/tax/cgt-disposals/{disposal_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_cgt_disposal( + disposal_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + try: + await tax_service.delete_manual_disposal(db, current_user.id, disposal_id) + await db.commit() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +# --------------------------------------------------------------------------- +# Tax report +# --------------------------------------------------------------------------- + +@router.get("/tax/report/{tax_year}", response_model=TaxReportResponse) +async def get_tax_report( + tax_year: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + try: + return await tax_service.build_tax_report(db, current_user.id, tax_year) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) diff --git a/backend/app/api/v1/transactions.py b/backend/app/api/v1/transactions.py index 5929479..1fd167b 100644 --- a/backend/app/api/v1/transactions.py +++ b/backend/app/api/v1/transactions.py @@ -611,6 +611,18 @@ async def import_transactions( return result +@router.post("/detect-recurring") +async def detect_recurring_endpoint( + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +): + """Manually trigger recurring transaction detection for the current user.""" + from app.services.recurring_service import detect_recurring + result = await detect_recurring(db, user.id) + await db.commit() + return result + + @router.get("/import/template") async def import_template(): from fastapi.responses import Response diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index 3cbaf31..96b7ef4 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -11,9 +11,11 @@ from app.db.models.investment_transaction import InvestmentTransaction from app.db.models.currency import Currency, ExchangeRate from app.db.models.net_worth_snapshot import NetWorthSnapshot from app.db.models.audit_log import AuditLog +from app.db.models.tax import TaxRateConfig, TaxProfile, Payslip, ManualCGTDisposal __all__ = [ "User", "Session", "Account", "Category", "Transaction", "Budget", "Asset", "AssetPrice", "InvestmentHolding", "InvestmentTransaction", "Currency", "ExchangeRate", "NetWorthSnapshot", "AuditLog", + "TaxRateConfig", "TaxProfile", "Payslip", "ManualCGTDisposal", ] diff --git a/backend/app/db/models/tax.py b/backend/app/db/models/tax.py new file mode 100644 index 0000000..b323203 --- /dev/null +++ b/backend/app/db/models/tax.py @@ -0,0 +1,74 @@ +import uuid +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, LargeBinary, Numeric, SmallInteger, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class TaxRateConfig(Base): + __tablename__ = "tax_rate_configs" + __table_args__ = ( + UniqueConstraint("user_id", "tax_year", "rate_type", name="uq_tax_rate_configs_user_year_type"), + ) + + 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) + tax_year: Mapped[int] = mapped_column(Integer, nullable=False) + rate_type: Mapped[str] = mapped_column(String(30), nullable=False) # income_tax|ni|cgt|dividend + config: Mapped[dict] = mapped_column(JSONB, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class TaxProfile(Base): + __tablename__ = "tax_profiles" + __table_args__ = ( + UniqueConstraint("user_id", "tax_year", name="uq_tax_profiles_user_year"), + ) + + 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) + tax_year: Mapped[int] = mapped_column(Integer, nullable=False) + employer_name_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + tax_code: Mapped[str] = mapped_column(String(20), nullable=False, default="1257L") + is_cumulative: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + payslips: Mapped[list["Payslip"]] = relationship(back_populates="tax_profile", lazy="noload") + + +class Payslip(Base): + __tablename__ = "payslips" + + 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) + tax_profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tax_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + period_month: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) + period_year: Mapped[int] = mapped_column(SmallInteger, nullable=False) + gross_pay: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) + income_tax_withheld: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) + ni_withheld: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) + net_pay: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) + is_p60: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + notes_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + tax_profile: Mapped["TaxProfile"] = relationship(back_populates="payslips", lazy="noload") + + +class ManualCGTDisposal(Base): + __tablename__ = "manual_cgt_disposals" + + 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) + tax_year: Mapped[int] = mapped_column(Integer, nullable=False) + disposal_date: Mapped[date] = mapped_column(Date, nullable=False) + asset_description_enc: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + proceeds: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) + cost_basis: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) + notes_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/backend/app/schemas/tax.py b/backend/app/schemas/tax.py new file mode 100644 index 0000000..a715fac --- /dev/null +++ b/backend/app/schemas/tax.py @@ -0,0 +1,215 @@ +import uuid +from datetime import date as DateType, datetime +from decimal import Decimal +from typing import Any + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Tax rate config +# --------------------------------------------------------------------------- + +class TaxRateConfigUpdate(BaseModel): + """PUT /tax/rate-configs/{tax_year} — pass only the rate types you want to upsert.""" + income_tax: dict[str, Any] | None = None + ni: dict[str, Any] | None = None + cgt: dict[str, Any] | None = None + dividend: dict[str, Any] | None = None + + +class TaxRateConfigResponse(BaseModel): + tax_year: int + rates: dict[str, Any] + updated_at: str + + +# --------------------------------------------------------------------------- +# Tax profile +# --------------------------------------------------------------------------- + +class TaxProfileCreate(BaseModel): + tax_code: str = Field(default="1257L", min_length=1, max_length=20) + employer_name: str | None = Field(default=None, max_length=200) + is_cumulative: bool = True + + +class TaxProfileResponse(BaseModel): + id: uuid.UUID + tax_year: int + tax_code: str + employer_name: str | None + is_cumulative: bool + created_at: str + updated_at: str + + +# --------------------------------------------------------------------------- +# Payslips +# --------------------------------------------------------------------------- + +class PayslipCreate(BaseModel): + period_month: int | None = Field(default=None, ge=1, le=12) + period_year: int = Field(..., ge=2000, le=2100) + gross_pay: Decimal = Field(..., ge=0) + income_tax_withheld: Decimal = Field(..., ge=0) + ni_withheld: Decimal = Field(..., ge=0) + net_pay: Decimal = Field(..., ge=0) + notes: str | None = None + + +class PayslipUpdate(BaseModel): + period_month: int | None = Field(default=None, ge=1, le=12) + period_year: int | None = Field(default=None, ge=2000, le=2100) + gross_pay: Decimal | None = Field(default=None, ge=0) + income_tax_withheld: Decimal | None = Field(default=None, ge=0) + ni_withheld: Decimal | None = Field(default=None, ge=0) + net_pay: Decimal | None = Field(default=None, ge=0) + notes: str | None = None + + +class PayslipResponse(BaseModel): + id: uuid.UUID + tax_profile_id: uuid.UUID + period_month: int | None + period_year: int + gross_pay: str + income_tax_withheld: str + ni_withheld: str + net_pay: str + is_p60: bool + notes: str | None + created_at: str + + +class P60Entry(BaseModel): + gross_pay: Decimal = Field(..., ge=0) + income_tax_withheld: Decimal = Field(..., ge=0) + ni_withheld: Decimal = Field(..., ge=0) + net_pay: Decimal = Field(..., ge=0) + + +# --------------------------------------------------------------------------- +# Manual CGT disposals +# --------------------------------------------------------------------------- + +class ManualDisposalCreate(BaseModel): + disposal_date: DateType + asset_description: str = Field(..., min_length=1, max_length=500) + proceeds: Decimal = Field(..., ge=0) + cost_basis: Decimal = Field(..., ge=0) + notes: str | None = None + + +class ManualDisposalUpdate(BaseModel): + disposal_date: DateType | None = None + asset_description: str | None = Field(default=None, min_length=1, max_length=500) + proceeds: Decimal | None = Field(default=None, ge=0) + cost_basis: Decimal | None = Field(default=None, ge=0) + notes: str | None = None + + +class ManualDisposalResponse(BaseModel): + id: uuid.UUID + tax_year: int + disposal_date: str + asset_description: str + proceeds: str + cost_basis: str + gain_loss: str + notes: str | None + created_at: str + + +# --------------------------------------------------------------------------- +# Tax report (nested) +# --------------------------------------------------------------------------- + +class BandBreakdownItem(BaseModel): + rate: float + taxable: float + tax: float + from_: int | None = Field(default=None, alias="from") + to: int | None = None + + model_config = {"populate_by_name": True} + + +class IncomeTaxSummary(BaseModel): + personal_allowance: str + taxable_income: str + liability: str + band_breakdown: list[dict[str, Any]] + withheld: str + owed: str + + +class NISummary(BaseModel): + liability: str + band_breakdown: list[dict[str, Any]] + withheld: str + owed: str + + +class InvestmentDisposalItem(BaseModel): + date: str + asset: str + symbol: str + quantity: str + proceeds: str + cost_basis: str + fees: str + gain_loss: str + + +class CGTSummary(BaseModel): + gross_gain: str + exempt: str + taxable_gain: str + liability: str + band_breakdown: list[dict[str, Any]] + investment_disposals: list[dict[str, Any]] + manual_disposals: list[dict[str, Any]] + total_gain: str + + +class DividendTransactionItem(BaseModel): + date: str + asset: str + symbol: str + amount: str + + +class DividendSummary(BaseModel): + gross_dividends: str + allowance: str + taxable_dividends: str + liability: str + band_breakdown: list[dict[str, Any]] + dividend_transactions: list[dict[str, Any]] + + +class TaxReportSummary(BaseModel): + total_liability: str + total_withheld: str + net_owed: str + overpaid: bool + + +class IncomeSummary(BaseModel): + gross_income: str + income_tax_withheld: str + ni_withheld: str + payslips: list[dict[str, Any]] + + +class TaxReportResponse(BaseModel): + tax_year: int + tax_year_display: str + profile: dict[str, Any] | None + income: IncomeSummary + income_tax: IncomeTaxSummary + ni: NISummary + cgt: CGTSummary + dividends: DividendSummary + summary: TaxReportSummary diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py index 5d10087..a5c01a3 100644 --- a/backend/app/schemas/transaction.py +++ b/backend/app/schemas/transaction.py @@ -35,6 +35,8 @@ class TransactionUpdate(BaseModel): merchant: str | None = None notes: str | None = None tags: list[str] | None = None + is_recurring: bool | None = None + recurring_rule: dict | None = None class TransactionFilter(BaseModel): @@ -71,6 +73,7 @@ class TransactionResponse(BaseModel): notes: str | None tags: list[str] is_recurring: bool + recurring_rule: dict | None = None attachment_refs: list[dict] = [] created_at: datetime updated_at: datetime diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 269acfe..29a4acb 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -85,6 +85,10 @@ async def register_user(db: AsyncSession, email: str, password: str, display_nam ) db.add(user) await db.flush() + + from app.services.tax_service import seed_default_rates + await seed_default_rates(db, user.id) + return user diff --git a/backend/app/services/recurring_service.py b/backend/app/services/recurring_service.py new file mode 100644 index 0000000..f231d0b --- /dev/null +++ b/backend/app/services/recurring_service.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import re +import uuid +from collections import defaultdict +from datetime import date, datetime, timezone +from decimal import Decimal +from statistics import mean, stdev + +from dateutil.relativedelta import relativedelta +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decrypt_field +from app.db.models.transaction import Transaction + +MIN_OCCURRENCES = 2 + +# (label, min_days, max_days, tolerance_days) +_FREQUENCIES = [ + ("weekly", 6, 8, 2), + ("fortnightly", 13, 15, 3), + ("monthly", 26, 35, 5), + ("quarterly", 85, 95, 10), + ("yearly", 355, 375, 15), +] + +_STRIP_PREFIXES = re.compile( + r"^(direct debit|standing order|faster payment|bacs|dd|so)\s+", + re.IGNORECASE, +) +_STRIP_REFS = re.compile(r"\s+\d{5,}$") +_STRIP_DATE_PATTERNS = re.compile( + r"\b\d{1,2}[/-]\d{1,2}([/-]\d{2,4})?\b" + r"|\b\d{1,2}(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b", + re.IGNORECASE, +) +_COLLAPSE_SPACES = re.compile(r"\s{2,}") + + +def normalise_description(raw: str) -> str: + s = raw.lower().strip() + s = _STRIP_PREFIXES.sub("", s) + s = _STRIP_DATE_PATTERNS.sub("", s) + s = _STRIP_REFS.sub("", s) + s = _COLLAPSE_SPACES.sub(" ", s).strip() + return s + + +def classify_frequency(avg_days: float) -> tuple[str, int] | None: + """Return (label, expected_days) or None if avg_days matches no known frequency.""" + for label, lo, hi, _ in _FREQUENCIES: + if lo <= avg_days <= hi: + return label, round(avg_days) + return None + + +def _within_tolerance(intervals: list[int], avg: float, frequency: str) -> bool: + tolerance = next(t for label, _, _, t in _FREQUENCIES if label == frequency) + return all(abs(iv - avg) <= tolerance for iv in intervals) + + +def next_expected_date(last: date, frequency: str) -> date: + delta_map = { + "weekly": relativedelta(weeks=1), + "fortnightly": relativedelta(weeks=2), + "monthly": relativedelta(months=1), + "quarterly": relativedelta(months=3), + "yearly": relativedelta(years=1), + } + delta = delta_map[frequency] + result = last + delta + today = date.today() + while result < today: + result += delta + return result + + +def _confidence(intervals: list[int], expected: int) -> float: + if len(intervals) < 2: + return 1.0 + try: + sd = stdev(intervals) + except Exception: + sd = 0.0 + conf = 1.0 - (sd / expected) if expected > 0 else 0.0 + return round(max(0.0, min(1.0, conf)), 4) + + +async def detect_recurring(db: AsyncSession, user_id: uuid.UUID) -> dict: + """ + Scan all transactions for a user, detect recurring patterns, and tag them. + Skips transactions where recurring_rule.manually_set == true. + Returns {"newly_tagged": int, "total_recurring": int}. + """ + result = await db.execute( + select(Transaction).where( + Transaction.user_id == user_id, + Transaction.deleted_at.is_(None), + ) + ) + transactions = result.scalars().all() + txn_map: dict[uuid.UUID, Transaction] = {t.id: t for t in transactions} + + # Group by (normalised_description, exact_amount) + keyed: dict[tuple[str, Decimal], tuple[list[date], list[uuid.UUID]]] = defaultdict( + lambda: ([], []) + ) + for txn in transactions: + try: + desc = decrypt_field(txn.description_enc) or "" + except Exception: + desc = "" + norm = normalise_description(desc) + amount = txn.amount.quantize(Decimal("0.01")) + dates_list, ids_list = keyed[(norm, amount)] + dates_list.append(txn.date) + ids_list.append(txn.id) + + now = datetime.now(timezone.utc) + newly_tagged = 0 + total_recurring = 0 + matched_ids: set[uuid.UUID] = set() + + for (norm_desc, amount), (dates_list, ids_list) in keyed.items(): + if len(dates_list) < MIN_OCCURRENCES: + continue + + paired = sorted(zip(dates_list, ids_list), key=lambda x: x[0]) + sorted_dates = [p[0] for p in paired] + sorted_ids = [p[1] for p in paired] + + intervals = [ + (sorted_dates[i + 1] - sorted_dates[i]).days + for i in range(len(sorted_dates) - 1) + ] + avg = mean(intervals) + + freq_result = classify_frequency(avg) + if freq_result is None: + continue + frequency, expected_days = freq_result + + if not _within_tolerance(intervals, avg, frequency): + continue + + conf = _confidence(intervals, expected_days) + last_date = sorted_dates[-1] + next_date = next_expected_date(last_date, frequency) + + for txn_id in sorted_ids: + txn = txn_map.get(txn_id) + if txn is None: + continue + + matched_ids.add(txn_id) + + rule = txn.recurring_rule or {} + if rule.get("manually_set"): + if txn.is_recurring: + total_recurring += 1 + continue + + was_recurring = txn.is_recurring + txn.is_recurring = True + txn.recurring_rule = { + "frequency": frequency, + "typical_amount": float(amount), + "next_expected": next_date.isoformat(), + "last_paid": last_date.isoformat(), + "confidence": conf, + "detected_at": now.isoformat(), + "manually_set": False, + } + txn.updated_at = now + + if not was_recurring: + newly_tagged += 1 + total_recurring += 1 + + # Un-tag previously auto-detected transactions whose pattern no longer matches + for txn in transactions: + if not txn.is_recurring: + continue + rule = txn.recurring_rule or {} + if rule.get("manually_set"): + continue + if txn.id not in matched_ids: + txn.is_recurring = False + txn.recurring_rule = None + txn.updated_at = now + + await db.flush() + return {"newly_tagged": newly_tagged, "total_recurring": total_recurring} diff --git a/backend/app/services/tax_calculations.py b/backend/app/services/tax_calculations.py new file mode 100644 index 0000000..9b47e78 --- /dev/null +++ b/backend/app/services/tax_calculations.py @@ -0,0 +1,301 @@ +""" +Pure UK tax calculation functions. Zero external dependencies — no DB, no ORM. +Each function receives a pre-loaded `rates` dict so they are fully unit-testable. + +Tax year convention: tax_year=N means 6 Apr (N-1) → 5 Apr N. +""" +from __future__ import annotations + +import re +from datetime import date +from decimal import ROUND_HALF_UP, Decimal +from typing import Any + + +# --------------------------------------------------------------------------- +# Tax year helpers +# --------------------------------------------------------------------------- + +def tax_year_for_date(d: date) -> int: + """Return tax_year int for a calendar date. tax_year=N = 6 Apr (N-1) → 5 Apr N.""" + if (d.month, d.day) >= (4, 6): + return d.year + 1 + return d.year + + +def tax_year_date_range(tax_year: int) -> tuple[date, date]: + """Return (start_date, end_date) inclusive for the given tax year.""" + return date(tax_year - 1, 4, 6), date(tax_year, 4, 5) + + +# --------------------------------------------------------------------------- +# Tax code parser +# --------------------------------------------------------------------------- + +def parse_tax_code(code: str) -> dict[str, Any]: + """Parse a UK tax code string. + + Returns: + allowance — annual personal allowance in £ (negative for K codes) + rate_override — flat rate (0.0–1.0) if code fixes a single rate, else None + k_code — True if K prefix (negative allowance) + no_tax — True if NT code + """ + raw = code.strip().upper() + raw = re.sub(r"[/\s]?(W1|M1)$", "", raw) + + if raw == "NT": + return {"allowance": Decimal("0"), "rate_override": Decimal("0"), "k_code": False, "no_tax": True} + if raw == "BR": + return {"allowance": Decimal("0"), "rate_override": Decimal("0.20"), "k_code": False, "no_tax": False} + if raw == "D0": + return {"allowance": Decimal("0"), "rate_override": Decimal("0.40"), "k_code": False, "no_tax": False} + if raw == "D1": + return {"allowance": Decimal("0"), "rate_override": Decimal("0.45"), "k_code": False, "no_tax": False} + if raw == "0T": + return {"allowance": Decimal("0"), "rate_override": None, "k_code": False, "no_tax": False} + + k_match = re.fullmatch(r"K(\d+)", raw) + if k_match: + return {"allowance": -Decimal(k_match.group(1)) * 10, "rate_override": None, "k_code": True, "no_tax": False} + + std_match = re.fullmatch(r"(\d+)[LMNTY]?", raw) + if std_match: + return {"allowance": Decimal(std_match.group(1)) * 10, "rate_override": None, "k_code": False, "no_tax": False} + + # Unknown code — treat as 0T + return {"allowance": Decimal("0"), "rate_override": None, "k_code": False, "no_tax": False} + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _apply_bands(amount: Decimal, bands: list[dict]) -> tuple[Decimal, list[dict]]: + total = Decimal("0") + breakdown = [] + for band in bands: + band_from = Decimal(str(band["from"])) + band_to = Decimal(str(band["to"])) if band["to"] is not None else None + rate = Decimal(str(band["rate"])) + + if amount <= band_from: + break + + upper = min(amount, band_to) if band_to is not None else amount + taxable_in_band = upper - band_from + tax_in_band = (taxable_in_band * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + total += tax_in_band + + if taxable_in_band > 0: + breakdown.append({ + "from": int(band_from), + "to": int(band_to) if band_to is not None else None, + "rate": float(rate), + "taxable": float(taxable_in_band), + "tax": float(tax_in_band), + }) + + return total, breakdown + + +def _personal_allowance_tapered(base_allowance: Decimal, gross_income: Decimal) -> Decimal: + """Reduce PA by £1 per £2 over £100,000; floor at zero at £125,140.""" + taper_threshold = Decimal("100000") + if gross_income <= taper_threshold: + return base_allowance + reduction = ((gross_income - taper_threshold) / 2).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + return max(Decimal("0"), base_allowance - reduction) + + +# --------------------------------------------------------------------------- +# Core calculation functions +# --------------------------------------------------------------------------- + +def calculate_income_tax( + gross_income: Decimal, + tax_code: str, + rates: dict, +) -> dict[str, Any]: + """Calculate income tax liability and remaining basic-rate band. + + Bands are applied to GROSS income. The 0% band threshold is adjusted to match + the actual personal allowance from the tax code (with taper applied if applicable). + K codes add their amount to gross income before applying the standard bands. + + Returns: + personal_allowance, taxable_income, liability, band_breakdown, + remaining_basic_rate_band (passed downstream to CGT/dividend calculations) + """ + parsed = parse_tax_code(tax_code) + bands = rates["income_tax"]["bands"] + # These are gross-income thresholds from the band definitions + pa_threshold = Decimal(str(next(b["to"] for b in bands if b["rate"] == 0.00))) + basic_rate_upper = Decimal(str(next(b["to"] for b in bands if b["rate"] == 0.20))) + + if parsed["no_tax"]: + return { + "personal_allowance": pa_threshold, + "taxable_income": Decimal("0"), + "liability": Decimal("0"), + "band_breakdown": [], + "remaining_basic_rate_band": max(Decimal("0"), basic_rate_upper - gross_income), + } + + if parsed["rate_override"] is not None: + liability = (gross_income * parsed["rate_override"]).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return { + "personal_allowance": Decimal("0"), + "taxable_income": gross_income, + "liability": liability, + "band_breakdown": [{"rate": float(parsed["rate_override"]), "tax": float(liability)}], + "remaining_basic_rate_band": Decimal("0"), + } + + if parsed["k_code"]: + # K codes: the K amount adds to taxable base; standard PA band still applies to effective gross. + # effective_gross is the "notional income" HMRC uses to calculate tax. + k_amount = abs(parsed["allowance"]) + effective_gross = gross_income + k_amount + personal_allowance = Decimal("0") # K code replaces any standard PA grant + taxable_income = effective_gross # reported as the effective taxable base + liability, band_breakdown = _apply_bands(effective_gross, bands) + remaining_brb = max(Decimal("0"), basic_rate_upper - effective_gross) + else: + base_pa = parsed["allowance"] + personal_allowance = _personal_allowance_tapered(base_pa, gross_income) if base_pa > 0 else base_pa + + # Adjust the 0% band to match the actual personal allowance, then apply to gross income. + adjusted_bands = [ + {"from": 0, "to": float(personal_allowance), "rate": 0.00} if b["rate"] == 0.00 else b + for b in bands + ] + taxable_income = max(Decimal("0"), gross_income - personal_allowance) + liability, band_breakdown = _apply_bands(gross_income, adjusted_bands) + remaining_brb = max(Decimal("0"), basic_rate_upper - gross_income) + + return { + "personal_allowance": personal_allowance, + "taxable_income": taxable_income, + "liability": liability, + "band_breakdown": band_breakdown, + "remaining_basic_rate_band": remaining_brb, + } + + +def calculate_ni(gross_income: Decimal, rates: dict) -> dict[str, Any]: + """Calculate primary Class 1 NI liability.""" + bands = rates["ni"]["bands"] + liability, band_breakdown = _apply_bands(gross_income, bands) + return {"liability": liability, "band_breakdown": band_breakdown} + + +def calculate_cgt( + total_gain: Decimal, + remaining_basic_rate_band: Decimal, + rates: dict, +) -> dict[str, Any]: + """Calculate CGT liability. + + Gains within the remaining basic-rate band are taxed at basic_rate; + gains above it at higher_rate. Annual exempt amount applied first. + """ + cgt_rates = rates["cgt"] + exempt = Decimal(str(cgt_rates["exempt"])) + basic_rate = Decimal(str(cgt_rates["basic_rate"])) + higher_rate = Decimal(str(cgt_rates["higher_rate"])) + + taxable_gain = max(Decimal("0"), total_gain - exempt) + + if taxable_gain == 0: + return { + "gross_gain": total_gain, + "exempt": min(total_gain, exempt) if total_gain > 0 else Decimal("0"), + "taxable_gain": Decimal("0"), + "liability": Decimal("0"), + "band_breakdown": [], + } + + basic_portion = min(taxable_gain, remaining_basic_rate_band) + higher_portion = taxable_gain - basic_portion + + liability = ( + (basic_portion * basic_rate) + (higher_portion * higher_rate) + ).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + breakdown = [] + if basic_portion > 0: + breakdown.append({"rate": float(basic_rate), "taxable": float(basic_portion), + "tax": float((basic_portion * basic_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))}) + if higher_portion > 0: + breakdown.append({"rate": float(higher_rate), "taxable": float(higher_portion), + "tax": float((higher_portion * higher_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))}) + + return { + "gross_gain": total_gain, + "exempt": min(total_gain, exempt), + "taxable_gain": taxable_gain, + "liability": liability, + "band_breakdown": breakdown, + } + + +def calculate_dividend_tax( + total_dividends: Decimal, + remaining_basic_rate_band: Decimal, + rates: dict, +) -> dict[str, Any]: + """Calculate dividend tax liability. + + Dividend allowance applied first; taxable dividends are then slotted into + the remaining income bands to determine which rate applies. + """ + div_rates = rates["dividend"] + allowance = Decimal(str(div_rates["allowance"])) + basic_rate = Decimal(str(div_rates["basic_rate"])) + higher_rate = Decimal(str(div_rates["higher_rate"])) + additional_rate = Decimal(str(div_rates["additional_rate"])) + + taxable_dividends = max(Decimal("0"), total_dividends - allowance) + + if taxable_dividends == 0: + return { + "gross_dividends": total_dividends, + "allowance": min(total_dividends, allowance), + "taxable_dividends": Decimal("0"), + "liability": Decimal("0"), + "band_breakdown": [], + } + + basic_portion = min(taxable_dividends, remaining_basic_rate_band) + remainder = taxable_dividends - basic_portion + higher_upper = Decimal(str( + next(b["to"] for b in rates["income_tax"]["bands"] if b["rate"] == 0.40) + )) + higher_portion = min(remainder, higher_upper) + additional_portion = remainder - higher_portion + + liability = ( + (basic_portion * basic_rate) + + (higher_portion * higher_rate) + + (additional_portion * additional_rate) + ).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + breakdown = [] + if basic_portion > 0: + breakdown.append({"rate": float(basic_rate), "taxable": float(basic_portion), + "tax": float((basic_portion * basic_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))}) + if higher_portion > 0: + breakdown.append({"rate": float(higher_rate), "taxable": float(higher_portion), + "tax": float((higher_portion * higher_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))}) + if additional_portion > 0: + breakdown.append({"rate": float(additional_rate), "taxable": float(additional_portion), + "tax": float((additional_portion * additional_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))}) + + return { + "gross_dividends": total_dividends, + "allowance": min(total_dividends, allowance), + "taxable_dividends": taxable_dividends, + "liability": liability, + "band_breakdown": breakdown, + } diff --git a/backend/app/services/tax_service.py b/backend/app/services/tax_service.py new file mode 100644 index 0000000..4d69a1d --- /dev/null +++ b/backend/app/services/tax_service.py @@ -0,0 +1,744 @@ +""" +UK tax service layer for MyMidas. + +Pure calculation functions live in tax_calculations.py (no DB imports). +This module provides the DB-backed service layer: rate loading, CRUD, report builder. +""" +from __future__ import annotations + +import uuid +from datetime import date, datetime, timezone +from decimal import Decimal +from typing import Any + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decrypt_field, encrypt_field +from app.services.tax_calculations import ( # noqa: F401 — re-exported for callers + calculate_cgt, + calculate_dividend_tax, + calculate_income_tax, + calculate_ni, + parse_tax_code, + tax_year_date_range, + tax_year_for_date, +) + + +# --------------------------------------------------------------------------- +# DB helpers +# --------------------------------------------------------------------------- + +async def load_rates(db: AsyncSession, user_id: uuid.UUID, tax_year: int) -> dict: + """Load and return rate config dict for a given user/year. + + Returns {"income_tax": {...}, "ni": {...}, "cgt": {...}, "dividend": {...}} + Raises ValueError if any rate type is missing for the requested year. + """ + from app.db.models.tax import TaxRateConfig + result = await db.execute( + select(TaxRateConfig).where( + TaxRateConfig.user_id == user_id, + TaxRateConfig.tax_year == tax_year, + ) + ) + rows = list(result.scalars()) + if not rows: + raise ValueError(f"No tax rate config found for year {tax_year}. Please configure rates first.") + + rates: dict = {} + for row in rows: + rates[row.rate_type] = row.config + + required = {"income_tax", "ni", "cgt", "dividend"} + missing = required - rates.keys() + if missing: + raise ValueError(f"Incomplete tax rate config for year {tax_year}: missing {missing}") + + return rates + + +async def seed_default_rates(db: AsyncSession, user_id: uuid.UUID) -> None: + """Insert default 2025 and 2026 rate configs for a newly registered user.""" + from app.db.models.tax import TaxRateConfig + + now = datetime.now(timezone.utc) + + income_tax_bands = { + "bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12570, "to": 50270, "rate": 0.20}, + {"from": 50270, "to": 125140, "rate": 0.40}, + {"from": 125140, "to": None, "rate": 0.45}, + ] + } + ni_bands = { + "bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12570, "to": 50270, "rate": 0.08}, + {"from": 50270, "to": None, "rate": 0.02}, + ] + } + cgt = {"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24} + dividend = { + "allowance": 500, + "basic_rate": 0.0875, + "higher_rate": 0.3375, + "additional_rate": 0.3935, + } + + defaults = { + "income_tax": income_tax_bands, + "ni": ni_bands, + "cgt": cgt, + "dividend": dividend, + } + + for tax_year in (2025, 2026): + for rate_type, config in defaults.items(): + existing = await db.execute( + select(TaxRateConfig).where( + TaxRateConfig.user_id == user_id, + TaxRateConfig.tax_year == tax_year, + TaxRateConfig.rate_type == rate_type, + ) + ) + if existing.scalar_one_or_none() is None: + db.add(TaxRateConfig( + id=uuid.uuid4(), + user_id=user_id, + tax_year=tax_year, + rate_type=rate_type, + config=config, + updated_at=now, + )) + + +# --------------------------------------------------------------------------- +# Tax profile CRUD +# --------------------------------------------------------------------------- + +async def get_tax_profile( + db: AsyncSession, user_id: uuid.UUID, tax_year: int +): + from app.db.models.tax import TaxProfile + result = await db.execute( + select(TaxProfile).where( + TaxProfile.user_id == user_id, + TaxProfile.tax_year == tax_year, + ) + ) + return result.scalar_one_or_none() + + +async def upsert_tax_profile( + db: AsyncSession, + user_id: uuid.UUID, + tax_year: int, + tax_code: str, + employer_name: str | None, + is_cumulative: bool, +): + from app.db.models.tax import TaxProfile + from app.core.audit import write_audit + + now = datetime.now(timezone.utc) + profile = await get_tax_profile(db, user_id, tax_year) + + employer_enc = encrypt_field(employer_name) if employer_name else None + + if profile is None: + profile = TaxProfile( + id=uuid.uuid4(), + user_id=user_id, + tax_year=tax_year, + tax_code=tax_code, + employer_name_enc=employer_enc, + is_cumulative=is_cumulative, + created_at=now, + updated_at=now, + ) + db.add(profile) + action = "tax_profile_create" + else: + profile.tax_code = tax_code + profile.employer_name_enc = employer_enc + profile.is_cumulative = is_cumulative + profile.updated_at = now + action = "tax_profile_update" + + await db.flush() + await write_audit(db, user_id=user_id, action=action, + resource_type="tax_profile", resource_id=profile.id) + return profile + + +def _profile_to_response(profile) -> dict: + from app.db.models.tax import TaxProfile + employer = None + if profile.employer_name_enc: + try: + employer = decrypt_field(profile.employer_name_enc) + except Exception: + employer = None + return { + "id": str(profile.id), + "tax_year": profile.tax_year, + "tax_code": profile.tax_code, + "employer_name": employer, + "is_cumulative": profile.is_cumulative, + "created_at": profile.created_at.isoformat(), + "updated_at": profile.updated_at.isoformat(), + } + + +# --------------------------------------------------------------------------- +# Payslip CRUD +# --------------------------------------------------------------------------- + +async def list_payslips(db: AsyncSession, user_id: uuid.UUID, tax_year: int) -> list: + from app.db.models.tax import Payslip, TaxProfile + result = await db.execute( + select(Payslip) + .join(TaxProfile, Payslip.tax_profile_id == TaxProfile.id) + .where( + Payslip.user_id == user_id, + TaxProfile.tax_year == tax_year, + ) + .order_by(Payslip.period_year, Payslip.period_month.nulls_last()) + ) + return list(result.scalars()) + + +async def create_payslip( + db: AsyncSession, + user_id: uuid.UUID, + tax_year: int, + period_month: int | None, + period_year: int, + gross_pay: Decimal, + income_tax_withheld: Decimal, + ni_withheld: Decimal, + net_pay: Decimal, + is_p60: bool = False, + notes: str | None = None, +): + from app.db.models.tax import Payslip + from app.core.audit import write_audit + + profile = await get_tax_profile(db, user_id, tax_year) + if profile is None: + raise ValueError(f"No tax profile for year {tax_year}. Create a profile first.") + + now = datetime.now(timezone.utc) + notes_enc = encrypt_field(notes) if notes else None + + payslip = Payslip( + id=uuid.uuid4(), + user_id=user_id, + tax_profile_id=profile.id, + period_month=period_month, + period_year=period_year, + gross_pay=gross_pay, + income_tax_withheld=income_tax_withheld, + ni_withheld=ni_withheld, + net_pay=net_pay, + is_p60=is_p60, + notes_enc=notes_enc, + created_at=now, + ) + db.add(payslip) + await db.flush() + await write_audit(db, user_id=user_id, action="payslip_create", + resource_type="payslip", resource_id=payslip.id) + return payslip + + +async def update_payslip( + db: AsyncSession, + user_id: uuid.UUID, + payslip_id: uuid.UUID, + **kwargs, +): + from app.db.models.tax import Payslip + from app.core.audit import write_audit + + result = await db.execute( + select(Payslip).where(Payslip.id == payslip_id, Payslip.user_id == user_id) + ) + payslip = result.scalar_one_or_none() + if payslip is None: + raise ValueError("Payslip not found") + + for field, value in kwargs.items(): + if field == "notes": + payslip.notes_enc = encrypt_field(value) if value else None + else: + setattr(payslip, field, value) + + await db.flush() + await write_audit(db, user_id=user_id, action="payslip_update", + resource_type="payslip", resource_id=payslip.id) + return payslip + + +async def delete_payslip(db: AsyncSession, user_id: uuid.UUID, payslip_id: uuid.UUID) -> None: + from app.db.models.tax import Payslip + from app.core.audit import write_audit + + result = await db.execute( + select(Payslip).where(Payslip.id == payslip_id, Payslip.user_id == user_id) + ) + payslip = result.scalar_one_or_none() + if payslip is None: + raise ValueError("Payslip not found") + + await write_audit(db, user_id=user_id, action="payslip_delete", + resource_type="payslip", resource_id=payslip_id) + await db.delete(payslip) + await db.flush() + + +async def replace_with_p60( + db: AsyncSession, + user_id: uuid.UUID, + tax_year: int, + gross_pay: Decimal, + income_tax_withheld: Decimal, + ni_withheld: Decimal, + net_pay: Decimal, +) -> None: + """Delete all existing payslips for the tax year and replace with a single P60.""" + from app.db.models.tax import Payslip, TaxProfile + from app.core.audit import write_audit + + profile = await get_tax_profile(db, user_id, tax_year) + if profile is None: + raise ValueError(f"No tax profile for year {tax_year}. Create a profile first.") + + await db.execute( + delete(Payslip).where( + Payslip.user_id == user_id, + Payslip.tax_profile_id == profile.id, + ) + ) + + now = datetime.now(timezone.utc) + p60 = Payslip( + id=uuid.uuid4(), + user_id=user_id, + tax_profile_id=profile.id, + period_month=None, + period_year=tax_year - 1, # P60 covers year ending 5 Apr tax_year; the employer year starts the prior calendar year + gross_pay=gross_pay, + income_tax_withheld=income_tax_withheld, + ni_withheld=ni_withheld, + net_pay=net_pay, + is_p60=True, + notes_enc=None, + created_at=now, + ) + db.add(p60) + await db.flush() + await write_audit(db, user_id=user_id, action="payslip_p60_replace", + resource_type="payslip", resource_id=p60.id) + + +def _payslip_to_response(payslip) -> dict: + notes = None + if payslip.notes_enc: + try: + notes = decrypt_field(payslip.notes_enc) + except Exception: + notes = None + return { + "id": str(payslip.id), + "tax_profile_id": str(payslip.tax_profile_id), + "period_month": payslip.period_month, + "period_year": payslip.period_year, + "gross_pay": str(payslip.gross_pay), + "income_tax_withheld": str(payslip.income_tax_withheld), + "ni_withheld": str(payslip.ni_withheld), + "net_pay": str(payslip.net_pay), + "is_p60": payslip.is_p60, + "notes": notes, + "created_at": payslip.created_at.isoformat(), + } + + +# --------------------------------------------------------------------------- +# Manual CGT disposal CRUD +# --------------------------------------------------------------------------- + +async def list_manual_disposals( + db: AsyncSession, user_id: uuid.UUID, tax_year: int +) -> list: + from app.db.models.tax import ManualCGTDisposal + result = await db.execute( + select(ManualCGTDisposal).where( + ManualCGTDisposal.user_id == user_id, + ManualCGTDisposal.tax_year == tax_year, + ).order_by(ManualCGTDisposal.disposal_date) + ) + return list(result.scalars()) + + +async def create_manual_disposal( + db: AsyncSession, + user_id: uuid.UUID, + tax_year: int, + disposal_date: date, + asset_description: str, + proceeds: Decimal, + cost_basis: Decimal, + notes: str | None = None, +): + from app.db.models.tax import ManualCGTDisposal + from app.core.audit import write_audit + + now = datetime.now(timezone.utc) + disposal = ManualCGTDisposal( + id=uuid.uuid4(), + user_id=user_id, + tax_year=tax_year, + disposal_date=disposal_date, + asset_description_enc=encrypt_field(asset_description), + proceeds=proceeds, + cost_basis=cost_basis, + notes_enc=encrypt_field(notes) if notes else None, + created_at=now, + ) + db.add(disposal) + await db.flush() + await write_audit(db, user_id=user_id, action="cgt_disposal_create", + resource_type="manual_cgt_disposal", resource_id=disposal.id) + return disposal + + +async def update_manual_disposal( + db: AsyncSession, + user_id: uuid.UUID, + disposal_id: uuid.UUID, + disposal_date: date, + asset_description: str, + proceeds: Decimal, + cost_basis: Decimal, + notes: str | None = None, +): + from app.db.models.tax import ManualCGTDisposal + from app.core.audit import write_audit + + result = await db.execute( + select(ManualCGTDisposal).where( + ManualCGTDisposal.id == disposal_id, + ManualCGTDisposal.user_id == user_id, + ) + ) + disposal = result.scalar_one_or_none() + if disposal is None: + raise ValueError("Disposal not found") + + disposal.disposal_date = disposal_date + disposal.asset_description_enc = encrypt_field(asset_description) + disposal.proceeds = proceeds + disposal.cost_basis = cost_basis + disposal.notes_enc = encrypt_field(notes) if notes else None + await db.flush() + await write_audit(db, user_id=user_id, action="cgt_disposal_update", + resource_type="manual_cgt_disposal", resource_id=disposal.id) + return disposal + + +async def delete_manual_disposal( + db: AsyncSession, user_id: uuid.UUID, disposal_id: uuid.UUID +) -> None: + from app.db.models.tax import ManualCGTDisposal + from app.core.audit import write_audit + + result = await db.execute( + select(ManualCGTDisposal).where( + ManualCGTDisposal.id == disposal_id, + ManualCGTDisposal.user_id == user_id, + ) + ) + disposal = result.scalar_one_or_none() + if disposal is None: + raise ValueError("Disposal not found") + + await write_audit(db, user_id=user_id, action="cgt_disposal_delete", + resource_type="manual_cgt_disposal", resource_id=disposal_id) + await db.delete(disposal) + await db.flush() + + +def _disposal_to_response(disposal) -> dict: + asset_desc = "" + try: + asset_desc = decrypt_field(disposal.asset_description_enc) + except Exception: + pass + notes = None + if disposal.notes_enc: + try: + notes = decrypt_field(disposal.notes_enc) + except Exception: + pass + gain_loss = disposal.proceeds - disposal.cost_basis + return { + "id": str(disposal.id), + "tax_year": disposal.tax_year, + "disposal_date": disposal.disposal_date.isoformat(), + "asset_description": asset_desc, + "proceeds": str(disposal.proceeds), + "cost_basis": str(disposal.cost_basis), + "gain_loss": str(gain_loss), + "notes": notes, + "created_at": disposal.created_at.isoformat(), + } + + +# --------------------------------------------------------------------------- +# Tax rate config CRUD +# --------------------------------------------------------------------------- + +async def list_configured_years(db: AsyncSession, user_id: uuid.UUID) -> list[int]: + from app.db.models.tax import TaxRateConfig + result = await db.execute( + select(TaxRateConfig.tax_year) + .where(TaxRateConfig.user_id == user_id) + .distinct() + .order_by(TaxRateConfig.tax_year) + ) + return [row[0] for row in result] + + +async def get_rate_config( + db: AsyncSession, user_id: uuid.UUID, tax_year: int +) -> dict: + from app.db.models.tax import TaxRateConfig + result = await db.execute( + select(TaxRateConfig).where( + TaxRateConfig.user_id == user_id, + TaxRateConfig.tax_year == tax_year, + ) + ) + rows = list(result.scalars()) + if not rows: + raise ValueError(f"No rate config for year {tax_year}") + return { + "tax_year": tax_year, + "rates": {row.rate_type: row.config for row in rows}, + "updated_at": max(row.updated_at for row in rows).isoformat(), + } + + +async def upsert_rate_config( + db: AsyncSession, + user_id: uuid.UUID, + tax_year: int, + rates: dict, +) -> dict: + from app.db.models.tax import TaxRateConfig + from app.core.audit import write_audit + + now = datetime.now(timezone.utc) + for rate_type, config in rates.items(): + result = await db.execute( + select(TaxRateConfig).where( + TaxRateConfig.user_id == user_id, + TaxRateConfig.tax_year == tax_year, + TaxRateConfig.rate_type == rate_type, + ) + ) + row = result.scalar_one_or_none() + if row is None: + db.add(TaxRateConfig( + id=uuid.uuid4(), + user_id=user_id, + tax_year=tax_year, + rate_type=rate_type, + config=config, + updated_at=now, + )) + else: + row.config = config + row.updated_at = now + + await db.flush() + await write_audit(db, user_id=user_id, action="tax_rate_config_update", + resource_type="tax_rate_config", + resource_id=None, + metadata={"tax_year": tax_year}) + return await get_rate_config(db, user_id, tax_year) + + +# --------------------------------------------------------------------------- +# Report builder +# --------------------------------------------------------------------------- + +async def build_tax_report( + db: AsyncSession, user_id: uuid.UUID, tax_year: int +) -> dict[str, Any]: + """Build the full tax report for a given year. + + Steps: + 1. Load rates + 2. Load profile + payslip totals + 3. Load investment sell disposals within the tax year + 4. Load investment dividend transactions within the tax year + 5. Load manual CGT disposals + 6. Run calculations: income tax → NI → CGT → dividend tax + 7. Return full report dict + """ + from app.db.models.tax import ManualCGTDisposal, Payslip, TaxProfile + from app.db.models.investment_transaction import InvestmentTransaction + from app.db.models.investment_holding import InvestmentHolding + from app.db.models.asset import Asset + + rates = await load_rates(db, user_id, tax_year) + start_date, end_date = tax_year_date_range(tax_year) + + # ---- Profile ---- + profile = await get_tax_profile(db, user_id, tax_year) + tax_code = profile.tax_code if profile else "1257L" + profile_data = _profile_to_response(profile) if profile else None + + # ---- Payslip totals ---- + payslips = await list_payslips(db, user_id, tax_year) + gross_income = sum((Decimal(str(p.gross_pay)) for p in payslips), Decimal("0")) + income_tax_withheld = sum((Decimal(str(p.income_tax_withheld)) for p in payslips), Decimal("0")) + ni_withheld = sum((Decimal(str(p.ni_withheld)) for p in payslips), Decimal("0")) + payslip_rows = [_payslip_to_response(p) for p in payslips] + + # ---- Investment sell disposals ---- + inv_disposals_result = await db.execute( + select(InvestmentTransaction, InvestmentHolding, Asset) + .join(InvestmentHolding, InvestmentTransaction.holding_id == InvestmentHolding.id) + .join(Asset, InvestmentHolding.asset_id == Asset.id) + .where( + InvestmentTransaction.user_id == user_id, + InvestmentTransaction.type == "sell", + InvestmentTransaction.date >= start_date, + InvestmentTransaction.date <= end_date, + ) + .order_by(InvestmentTransaction.date) + ) + inv_disposal_rows = [] + total_inv_gain = Decimal("0") + for inv_txn, holding, asset in inv_disposals_result: + proceeds = Decimal(str(inv_txn.total_amount)) + cost = Decimal(str(inv_txn.quantity)) * Decimal(str(holding.avg_cost_basis)) + gain = proceeds - cost - Decimal(str(inv_txn.fees)) + total_inv_gain += gain + inv_disposal_rows.append({ + "date": inv_txn.date.isoformat(), + "asset": asset.name, + "symbol": asset.symbol, + "quantity": str(inv_txn.quantity), + "proceeds": str(proceeds), + "cost_basis": str(cost), + "fees": str(inv_txn.fees), + "gain_loss": str(gain), + }) + + # ---- Manual CGT disposals ---- + manual_disposals = await list_manual_disposals(db, user_id, tax_year) + manual_disposal_rows = [_disposal_to_response(d) for d in manual_disposals] + total_manual_gain = sum( + (Decimal(str(d.proceeds)) - Decimal(str(d.cost_basis)) for d in manual_disposals), + Decimal("0"), + ) + total_cgt_gain = total_inv_gain + total_manual_gain + + # ---- Investment dividends ---- + div_result = await db.execute( + select(InvestmentTransaction, InvestmentHolding, Asset) + .join(InvestmentHolding, InvestmentTransaction.holding_id == InvestmentHolding.id) + .join(Asset, InvestmentHolding.asset_id == Asset.id) + .where( + InvestmentTransaction.user_id == user_id, + InvestmentTransaction.type == "dividend", + InvestmentTransaction.date >= start_date, + InvestmentTransaction.date <= end_date, + ) + .order_by(InvestmentTransaction.date) + ) + dividend_rows = [] + total_dividends = Decimal("0") + for inv_txn, holding, asset in div_result: + amount = Decimal(str(inv_txn.total_amount)) + total_dividends += amount + dividend_rows.append({ + "date": inv_txn.date.isoformat(), + "asset": asset.name, + "symbol": asset.symbol, + "amount": str(amount), + }) + + # ---- Calculations ---- + income_tax_result = calculate_income_tax(gross_income, tax_code, rates) + ni_result = calculate_ni(gross_income, rates) + cgt_result = calculate_cgt( + total_cgt_gain, + income_tax_result["remaining_basic_rate_band"], + rates, + ) + dividend_result = calculate_dividend_tax( + total_dividends, + income_tax_result["remaining_basic_rate_band"], + rates, + ) + + # ---- Totals ---- + total_liability = ( + income_tax_result["liability"] + + ni_result["liability"] + + cgt_result["liability"] + + dividend_result["liability"] + ) + total_withheld = income_tax_withheld + ni_withheld + net_owed = total_liability - total_withheld + + return { + "tax_year": tax_year, + "tax_year_display": f"{tax_year - 1}/{str(tax_year)[2:]}", + "profile": profile_data, + "income": { + "gross_income": str(gross_income), + "income_tax_withheld": str(income_tax_withheld), + "ni_withheld": str(ni_withheld), + "payslips": payslip_rows, + }, + "income_tax": { + **{k: str(v) if isinstance(v, Decimal) else v + for k, v in income_tax_result.items() + if k != "remaining_basic_rate_band"}, + "withheld": str(income_tax_withheld), + "owed": str(income_tax_result["liability"] - income_tax_withheld), + }, + "ni": { + **{k: str(v) if isinstance(v, Decimal) else v + for k, v in ni_result.items()}, + "withheld": str(ni_withheld), + "owed": str(ni_result["liability"] - ni_withheld), + }, + "cgt": { + **{k: str(v) if isinstance(v, Decimal) else v + for k, v in cgt_result.items()}, + "investment_disposals": inv_disposal_rows, + "manual_disposals": manual_disposal_rows, + "total_gain": str(total_cgt_gain), + }, + "dividends": { + **{k: str(v) if isinstance(v, Decimal) else v + for k, v in dividend_result.items()}, + "dividend_transactions": dividend_rows, + }, + "summary": { + "total_liability": str(total_liability), + "total_withheld": str(total_withheld), + "net_owed": str(net_owed), + "overpaid": net_owed < 0, + }, + } diff --git a/backend/app/services/transaction_service.py b/backend/app/services/transaction_service.py index ad2af19..97e42fa 100644 --- a/backend/app/services/transaction_service.py +++ b/backend/app/services/transaction_service.py @@ -47,6 +47,7 @@ def _to_response(t: Transaction) -> dict: "notes": _dec(t.notes_enc), "tags": t.tags or [], "is_recurring": t.is_recurring, + "recurring_rule": t.recurring_rule, "attachment_refs": t.attachment_refs or [], "created_at": t.created_at, "updated_at": t.updated_at, @@ -221,6 +222,10 @@ async def update_transaction( txn.notes_enc = _enc(data.notes) if data.tags is not None: txn.tags = data.tags + if data.is_recurring is not None: + txn.is_recurring = data.is_recurring + if data.recurring_rule is not None: + txn.recurring_rule = data.recurring_rule txn.updated_at = now await db.flush() @@ -307,5 +312,7 @@ async def import_csv( await db.flush() if imported > 0: await recalculate_balance(db, account_id) + from app.services.recurring_service import detect_recurring + await detect_recurring(db, user_id) return {"imported": imported, "skipped": skipped} diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..68e8f9c --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,9 @@ +""" +Conftest for backend tests. +Pure calculation tests (test_tax_calculations.py) import from tax_calculations.py +which has no external dependencies, so no stubs are needed. +""" +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) diff --git a/backend/tests/test_tax_calculations.py b/backend/tests/test_tax_calculations.py new file mode 100644 index 0000000..20e4179 --- /dev/null +++ b/backend/tests/test_tax_calculations.py @@ -0,0 +1,311 @@ +""" +Unit tests for the pure tax calculation functions. + +Reference figures verified against HMRC's tax calculator and HMRC guidance. +All tests use the 2025/26 frozen rate structure (same as seed data). +""" +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from decimal import Decimal +from datetime import date +import pytest + +from app.services.tax_calculations import ( + tax_year_for_date, + parse_tax_code, + calculate_income_tax, + calculate_ni, + calculate_cgt, + calculate_dividend_tax, +) + + +# --------------------------------------------------------------------------- +# Shared rate fixture (mirrors the seeded data) +# --------------------------------------------------------------------------- + +RATES = { + "income_tax": { + "bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12570, "to": 50270, "rate": 0.20}, + {"from": 50270, "to": 125140, "rate": 0.40}, + {"from": 125140, "to": None, "rate": 0.45}, + ] + }, + "ni": { + "bands": [ + {"from": 0, "to": 12570, "rate": 0.00}, + {"from": 12570, "to": 50270, "rate": 0.08}, + {"from": 50270, "to": None, "rate": 0.02}, + ] + }, + "cgt": {"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24}, + "dividend": { + "allowance": 500, + "basic_rate": 0.0875, + "higher_rate": 0.3375, + "additional_rate": 0.3935, + }, +} + + +# --------------------------------------------------------------------------- +# tax_year_for_date +# --------------------------------------------------------------------------- + +class TestTaxYearForDate: + def test_before_april_6(self): + assert tax_year_for_date(date(2025, 4, 5)) == 2025 + + def test_on_april_6(self): + assert tax_year_for_date(date(2025, 4, 6)) == 2026 + + def test_mid_year(self): + assert tax_year_for_date(date(2025, 10, 1)) == 2026 + + def test_january(self): + assert tax_year_for_date(date(2026, 1, 15)) == 2026 + + def test_april_5_boundary(self): + # 5 April 2024 → tax_year 2024 + assert tax_year_for_date(date(2024, 4, 5)) == 2024 + + def test_april_6_boundary(self): + # 6 April 2024 → tax_year 2025 + assert tax_year_for_date(date(2024, 4, 6)) == 2025 + + +# --------------------------------------------------------------------------- +# parse_tax_code +# --------------------------------------------------------------------------- + +class TestParseTaxCode: + def test_standard_1257l(self): + r = parse_tax_code("1257L") + assert r["allowance"] == Decimal("12570") + assert r["rate_override"] is None + assert r["k_code"] is False + assert r["no_tax"] is False + + def test_standard_1257m(self): + assert parse_tax_code("1257M")["allowance"] == Decimal("12570") + + def test_standard_1257n(self): + assert parse_tax_code("1257N")["allowance"] == Decimal("12570") + + def test_br(self): + r = parse_tax_code("BR") + assert r["allowance"] == Decimal("0") + assert r["rate_override"] == Decimal("0.20") + + def test_d0(self): + r = parse_tax_code("D0") + assert r["rate_override"] == Decimal("0.40") + + def test_d1(self): + r = parse_tax_code("D1") + assert r["rate_override"] == Decimal("0.45") + + def test_nt(self): + r = parse_tax_code("NT") + assert r["no_tax"] is True + + def test_0t(self): + r = parse_tax_code("0T") + assert r["allowance"] == Decimal("0") + assert r["rate_override"] is None + + def test_k_code(self): + r = parse_tax_code("K100") + assert r["allowance"] == Decimal("-1000") + assert r["k_code"] is True + + def test_k_code_large(self): + r = parse_tax_code("K497") + assert r["allowance"] == Decimal("-4970") + + def test_w1_suffix_stripped(self): + r = parse_tax_code("1257L W1") + assert r["allowance"] == Decimal("12570") + + def test_m1_suffix_stripped(self): + r = parse_tax_code("1257L/M1") + assert r["allowance"] == Decimal("12570") + + def test_lowercase(self): + r = parse_tax_code("1257l") + assert r["allowance"] == Decimal("12570") + + +# --------------------------------------------------------------------------- +# calculate_income_tax +# --------------------------------------------------------------------------- + +class TestCalculateIncomeTax: + def test_below_personal_allowance(self): + r = calculate_income_tax(Decimal("10000"), "1257L", RATES) + assert r["liability"] == Decimal("0") + assert r["taxable_income"] == Decimal("0") + assert r["personal_allowance"] == Decimal("12570") + + def test_at_personal_allowance_boundary(self): + r = calculate_income_tax(Decimal("12570"), "1257L", RATES) + assert r["liability"] == Decimal("0") + + def test_basic_rate_salary_30k(self): + # £30,000 gross — taxable = £17,430, basic rate 20% + r = calculate_income_tax(Decimal("30000"), "1257L", RATES) + assert r["taxable_income"] == Decimal("17430") + assert r["liability"] == Decimal("3486.00") + + def test_at_higher_rate_threshold(self): + # £50,270 gross — exactly at the basic rate upper; taxable = £37,700 + r = calculate_income_tax(Decimal("50270"), "1257L", RATES) + assert r["taxable_income"] == Decimal("37700") + # 20% on (50270 - 12570) = 37700 + expected = (Decimal("37700") * Decimal("0.20")).quantize(Decimal("0.01")) + assert r["liability"] == expected + + def test_higher_rate_taxpayer_60k(self): + # £60,000 gross, 1257L + # 20% on (50270 - 12570) = 37700 → £7,540 + # 40% on (60000 - 50270) = 9730 → £3,892 + r = calculate_income_tax(Decimal("60000"), "1257L", RATES) + assert r["taxable_income"] == Decimal("47430") # 60000 - 12570 + expected_basic = (Decimal("37700") * Decimal("0.20")).quantize(Decimal("0.01")) + expected_higher = (Decimal("9730") * Decimal("0.40")).quantize(Decimal("0.01")) + assert r["liability"] == expected_basic + expected_higher + + def test_personal_allowance_taper_110k(self): + # £110,000 — allowance tapered by £5,000 → £7,570 + r = calculate_income_tax(Decimal("110000"), "1257L", RATES) + assert r["personal_allowance"] == Decimal("7570") + + def test_personal_allowance_taper_125140(self): + # At £125,140 allowance tapers to zero + r = calculate_income_tax(Decimal("125140"), "1257L", RATES) + assert r["personal_allowance"] == Decimal("0") + + def test_personal_allowance_above_125140(self): + # Above £125,140 allowance stays at zero + r = calculate_income_tax(Decimal("150000"), "1257L", RATES) + assert r["personal_allowance"] == Decimal("0") + + def test_br_code(self): + r = calculate_income_tax(Decimal("30000"), "BR", RATES) + assert r["liability"] == (Decimal("30000") * Decimal("0.20")).quantize(Decimal("0.01")) + + def test_d0_code(self): + r = calculate_income_tax(Decimal("30000"), "D0", RATES) + assert r["liability"] == (Decimal("30000") * Decimal("0.40")).quantize(Decimal("0.01")) + + def test_nt_code(self): + r = calculate_income_tax(Decimal("100000"), "NT", RATES) + assert r["liability"] == Decimal("0") + + def test_k_code_increases_taxable(self): + # K100 = -£1000 allowance → taxable = gross + £1000 + r = calculate_income_tax(Decimal("30000"), "K100", RATES) + assert r["taxable_income"] == Decimal("31000") + + def test_remaining_basic_rate_band_basic_taxpayer(self): + # £30k gross → remaining = 50270 - 30000 = 20270 + r = calculate_income_tax(Decimal("30000"), "1257L", RATES) + assert r["remaining_basic_rate_band"] == Decimal("20270") + + def test_remaining_basic_rate_band_higher_taxpayer(self): + # £60k gross > £50270 → band exhausted + r = calculate_income_tax(Decimal("60000"), "1257L", RATES) + assert r["remaining_basic_rate_band"] == Decimal("0") + + +# --------------------------------------------------------------------------- +# calculate_ni +# --------------------------------------------------------------------------- + +class TestCalculateNI: + def test_below_threshold(self): + r = calculate_ni(Decimal("12570"), RATES) + assert r["liability"] == Decimal("0") + + def test_basic_rate_salary_30k(self): + # NI on (30000 - 12570) = £17,430 at 8% = £1,394.40 + r = calculate_ni(Decimal("30000"), RATES) + assert r["liability"] == Decimal("1394.40") + + def test_above_upper_earnings_limit(self): + # NI on (50270 - 12570) = £37,700 at 8% = £3,016 + (60000 - 50270) = £9,730 at 2% = £194.60 + r = calculate_ni(Decimal("60000"), RATES) + assert r["liability"] == Decimal("3016.00") + Decimal("194.60") + + +# --------------------------------------------------------------------------- +# calculate_cgt +# --------------------------------------------------------------------------- + +class TestCalculateCGT: + def test_below_exempt(self): + r = calculate_cgt(Decimal("1000"), Decimal("20000"), RATES) + assert r["liability"] == Decimal("0") + assert r["taxable_gain"] == Decimal("0") + + def test_exactly_exempt(self): + r = calculate_cgt(Decimal("3000"), Decimal("20000"), RATES) + assert r["liability"] == Decimal("0") + + def test_basic_rate_taxpayer(self): + # Gain £10,000 — exempt £3,000 — taxable £7,000 all at basic rate 18% + r = calculate_cgt(Decimal("10000"), Decimal("20000"), RATES) + assert r["taxable_gain"] == Decimal("7000") + assert r["liability"] == (Decimal("7000") * Decimal("0.18")).quantize(Decimal("0.01")) + + def test_higher_rate_taxpayer(self): + # remaining_basic_rate_band = 0 → all at higher rate 24% + r = calculate_cgt(Decimal("10000"), Decimal("0"), RATES) + assert r["liability"] == (Decimal("7000") * Decimal("0.24")).quantize(Decimal("0.01")) + + def test_split_basic_higher(self): + # taxable gain £7,000; remaining_brb £4,000 → £4k at 18%, £3k at 24% + r = calculate_cgt(Decimal("10000"), Decimal("4000"), RATES) + expected = ( + (Decimal("4000") * Decimal("0.18")) + + (Decimal("3000") * Decimal("0.24")) + ).quantize(Decimal("0.01")) + assert r["liability"] == expected + + def test_negative_gain_no_tax(self): + r = calculate_cgt(Decimal("-5000"), Decimal("20000"), RATES) + assert r["liability"] == Decimal("0") + assert r["taxable_gain"] == Decimal("0") + + +# --------------------------------------------------------------------------- +# calculate_dividend_tax +# --------------------------------------------------------------------------- + +class TestCalculateDividendTax: + def test_within_allowance(self): + r = calculate_dividend_tax(Decimal("400"), Decimal("20000"), RATES) + assert r["liability"] == Decimal("0") + + def test_exactly_allowance(self): + r = calculate_dividend_tax(Decimal("500"), Decimal("20000"), RATES) + assert r["liability"] == Decimal("0") + + def test_basic_rate_band(self): + # £1,500 dividends — allowance £500 — taxable £1,000 at basic 8.75% + r = calculate_dividend_tax(Decimal("1500"), Decimal("20000"), RATES) + assert r["taxable_dividends"] == Decimal("1000") + assert r["liability"] == (Decimal("1000") * Decimal("0.0875")).quantize(Decimal("0.01")) + + def test_higher_rate_band(self): + # Remaining basic = 0 → taxable £1,000 at higher 33.75% + r = calculate_dividend_tax(Decimal("1500"), Decimal("0"), RATES) + assert r["liability"] == (Decimal("1000") * Decimal("0.3375")).quantize(Decimal("0.01")) + + def test_no_dividends(self): + r = calculate_dividend_tax(Decimal("0"), Decimal("20000"), RATES) + assert r["liability"] == Decimal("0") diff --git a/backend/tests/test_tax_schemas.py b/backend/tests/test_tax_schemas.py new file mode 100644 index 0000000..f085d8c --- /dev/null +++ b/backend/tests/test_tax_schemas.py @@ -0,0 +1,262 @@ +""" +Schema round-trip tests for tax.py Pydantic models. + +Verifies that each schema accepts valid data, rejects invalid data, +and that the nested TaxReportResponse correctly validates the shape +returned by build_tax_report(). +""" +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import uuid +from datetime import date +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from app.schemas.tax import ( + ManualDisposalCreate, + ManualDisposalUpdate, + P60Entry, + PayslipCreate, + PayslipUpdate, + TaxProfileCreate, + TaxRateConfigUpdate, + TaxReportResponse, +) + + +# --------------------------------------------------------------------------- +# TaxRateConfigUpdate +# --------------------------------------------------------------------------- + +class TestTaxRateConfigUpdate: + def test_partial_update_accepted(self): + u = TaxRateConfigUpdate(cgt={"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24}) + assert u.cgt["exempt"] == 3000 + assert u.income_tax is None + + def test_all_none_valid(self): + u = TaxRateConfigUpdate() + assert u.income_tax is None + assert u.ni is None + + +# --------------------------------------------------------------------------- +# TaxProfileCreate +# --------------------------------------------------------------------------- + +class TestTaxProfileCreate: + def test_defaults(self): + p = TaxProfileCreate() + assert p.tax_code == "1257L" + assert p.is_cumulative is True + assert p.employer_name is None + + def test_custom_values(self): + p = TaxProfileCreate(tax_code="BR", employer_name="Acme Ltd", is_cumulative=False) + assert p.tax_code == "BR" + assert p.employer_name == "Acme Ltd" + + def test_tax_code_too_long(self): + with pytest.raises(ValidationError): + TaxProfileCreate(tax_code="X" * 21) + + +# --------------------------------------------------------------------------- +# PayslipCreate +# --------------------------------------------------------------------------- + +class TestPayslipCreate: + def test_valid(self): + p = PayslipCreate( + period_month=4, + period_year=2024, + gross_pay=Decimal("3000.00"), + income_tax_withheld=Decimal("286.00"), + ni_withheld=Decimal("220.00"), + net_pay=Decimal("2494.00"), + ) + assert p.period_month == 4 + assert p.gross_pay == Decimal("3000.00") + + def test_invalid_month(self): + with pytest.raises(ValidationError): + PayslipCreate( + period_month=13, + period_year=2024, + gross_pay=Decimal("3000"), + income_tax_withheld=Decimal("286"), + ni_withheld=Decimal("220"), + net_pay=Decimal("2494"), + ) + + def test_negative_gross_rejected(self): + with pytest.raises(ValidationError): + PayslipCreate( + period_month=4, + period_year=2024, + gross_pay=Decimal("-1"), + income_tax_withheld=Decimal("0"), + ni_withheld=Decimal("0"), + net_pay=Decimal("0"), + ) + + def test_p60_no_month(self): + p = PayslipCreate( + period_month=None, + period_year=2024, + gross_pay=Decimal("36000"), + income_tax_withheld=Decimal("4686"), + ni_withheld=Decimal("2394"), + net_pay=Decimal("28920"), + ) + assert p.period_month is None + + +# --------------------------------------------------------------------------- +# P60Entry +# --------------------------------------------------------------------------- + +class TestP60Entry: + def test_valid(self): + e = P60Entry( + gross_pay=Decimal("36000"), + income_tax_withheld=Decimal("4686"), + ni_withheld=Decimal("2394"), + net_pay=Decimal("28920"), + ) + assert e.gross_pay == Decimal("36000") + + def test_negative_rejected(self): + with pytest.raises(ValidationError): + P60Entry( + gross_pay=Decimal("-1"), + income_tax_withheld=Decimal("0"), + ni_withheld=Decimal("0"), + net_pay=Decimal("0"), + ) + + +# --------------------------------------------------------------------------- +# ManualDisposalCreate +# --------------------------------------------------------------------------- + +class TestManualDisposalCreate: + def test_valid(self): + d = ManualDisposalCreate( + disposal_date=date(2025, 1, 15), + asset_description="Rental property", + proceeds=Decimal("250000"), + cost_basis=Decimal("200000"), + ) + assert d.proceeds == Decimal("250000") + assert d.notes is None + + def test_empty_description_rejected(self): + with pytest.raises(ValidationError): + ManualDisposalCreate( + disposal_date=date(2025, 1, 15), + asset_description="", + proceeds=Decimal("1000"), + cost_basis=Decimal("500"), + ) + + def test_negative_proceeds_rejected(self): + with pytest.raises(ValidationError): + ManualDisposalCreate( + disposal_date=date(2025, 1, 15), + asset_description="Something", + proceeds=Decimal("-1"), + cost_basis=Decimal("500"), + ) + + +# --------------------------------------------------------------------------- +# TaxReportResponse — validates the full nested report shape +# --------------------------------------------------------------------------- + +SAMPLE_REPORT = { + "tax_year": 2025, + "tax_year_display": "2024/25", + "profile": { + "id": str(uuid.uuid4()), + "tax_year": 2025, + "tax_code": "1257L", + "employer_name": "Acme Ltd", + "is_cumulative": True, + "created_at": "2025-01-01T00:00:00+00:00", + "updated_at": "2025-01-01T00:00:00+00:00", + }, + "income": { + "gross_income": "45000.00", + "income_tax_withheld": "6486.00", + "ni_withheld": "2634.00", + "payslips": [], + }, + "income_tax": { + "personal_allowance": "12570.00", + "taxable_income": "32430.00", + "liability": "6486.00", + "band_breakdown": [{"rate": 0.20, "taxable": 32430.0, "tax": 6486.0}], + "withheld": "6486.00", + "owed": "0.00", + }, + "ni": { + "liability": "2634.00", + "band_breakdown": [{"rate": 0.08, "taxable": 32430.0, "tax": 2594.4}], + "withheld": "2634.00", + "owed": "0.00", + }, + "cgt": { + "gross_gain": "0.00", + "exempt": "0.00", + "taxable_gain": "0.00", + "liability": "0.00", + "band_breakdown": [], + "investment_disposals": [], + "manual_disposals": [], + "total_gain": "0.00", + }, + "dividends": { + "gross_dividends": "0.00", + "allowance": "0.00", + "taxable_dividends": "0.00", + "liability": "0.00", + "band_breakdown": [], + "dividend_transactions": [], + }, + "summary": { + "total_liability": "9120.00", + "total_withheld": "9120.00", + "net_owed": "0.00", + "overpaid": False, + }, +} + + +class TestTaxReportResponse: + def test_valid_report_parses(self): + r = TaxReportResponse(**SAMPLE_REPORT) + assert r.tax_year == 2025 + assert r.tax_year_display == "2024/25" + assert r.summary.net_owed == "0.00" + assert r.summary.overpaid is False + + def test_no_profile(self): + report = {**SAMPLE_REPORT, "profile": None} + r = TaxReportResponse(**report) + assert r.profile is None + + def test_missing_summary_field_rejected(self): + bad_summary = {**SAMPLE_REPORT["summary"]} + del bad_summary["overpaid"] + with pytest.raises(ValidationError): + TaxReportResponse(**{**SAMPLE_REPORT, "summary": bad_summary}) + + def test_missing_income_field_rejected(self): + bad_income = {**SAMPLE_REPORT["income"]} + del bad_income["gross_income"] + with pytest.raises(ValidationError): + TaxReportResponse(**{**SAMPLE_REPORT, "income": bad_income}) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..3064b64 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,30 @@ +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import reactHooks from "eslint-plugin-react-hooks"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["src/**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, + }, + plugins: { + "@typescript-eslint": tseslint, + "react-hooks": reactHooks, + }, + rules: { + ...tseslint.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "warn", + }, + }, +]; diff --git a/frontend/package.json b/frontend/package.json index 31228d6..fb06bde 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "eslint src --ext ts,tsx" + "lint": "eslint src" }, "dependencies": { "react": "^18.3.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f172bea..8ff9e8f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,8 @@ import PortfolioPage from "@/pages/investments/PortfolioPage"; import AssetDetail from "@/pages/investments/AssetDetail"; import PredictionsPage from "@/pages/predictions/PredictionsPage"; import SettingsPage from "@/pages/settings/SettingsPage"; +import SubscriptionsPage from "@/pages/subscriptions/SubscriptionsPage"; +import TaxPage from "@/pages/tax/TaxPage"; function PrivateRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); @@ -53,7 +55,9 @@ export default function App() { } /> } /> } /> + } /> } /> + } /> } /> diff --git a/frontend/src/api/subscriptions.ts b/frontend/src/api/subscriptions.ts new file mode 100644 index 0000000..dcd45df --- /dev/null +++ b/frontend/src/api/subscriptions.ts @@ -0,0 +1,28 @@ +import { api } from "./client"; + +export interface Subscription { + name: string; + amount: number; + frequency: string; + next_expected: string | null; + last_paid: string | null; + account_id: string; + account_name: string | null; + transaction_ids: string[]; + latest_transaction_id: string; + monthly_equivalent: number; + confidence: number; + manually_set: boolean; +} + +export interface SubscriptionsSummary { + total_monthly_equivalent: number; + currency: string; + subscriptions: Subscription[]; +} + +export const getSubscriptions = (): Promise => + api.get("/subscriptions").then((r: { data: SubscriptionsSummary }) => r.data); + +export const triggerDetection = (): Promise<{ newly_tagged: number; total_recurring: number }> => + api.post("/transactions/detect-recurring").then((r: { data: { newly_tagged: number; total_recurring: number } }) => r.data); diff --git a/frontend/src/api/tax.ts b/frontend/src/api/tax.ts new file mode 100644 index 0000000..9905abe --- /dev/null +++ b/frontend/src/api/tax.ts @@ -0,0 +1,309 @@ +import { api } from "./client"; + +// --------------------------------------------------------------------------- +// Query key constants +// --------------------------------------------------------------------------- + +export const TAX_QUERY_KEYS = { + configuredYears: ["tax-configured-years"] as const, + rateConfig: (taxYear: number) => ["tax-rate-config", taxYear] as const, + profile: (taxYear: number) => ["tax-profile", taxYear] as const, + payslips: (taxYear: number) => ["tax-payslips", taxYear] as const, + cgtDisposals: (taxYear: number) => ["tax-cgt-disposals", taxYear] as const, + report: (taxYear: number) => ["tax-report", taxYear] as const, +}; + +// --------------------------------------------------------------------------- +// Interfaces +// --------------------------------------------------------------------------- + +export interface TaxRateConfig { + tax_year: number; + rates: { + income_tax?: { bands: RateBand[] }; + ni?: { bands: RateBand[] }; + cgt?: { exempt: number; basic_rate: number; higher_rate: number }; + dividend?: { + allowance: number; + basic_rate: number; + higher_rate: number; + additional_rate: number; + }; + }; + updated_at: string; +} + +export interface RateBand { + from: number; + to: number | null; + rate: number; +} + +export interface TaxRateConfigUpdate { + income_tax?: { bands: RateBand[] }; + ni?: { bands: RateBand[] }; + cgt?: { exempt: number; basic_rate: number; higher_rate: number }; + dividend?: { + allowance: number; + basic_rate: number; + higher_rate: number; + additional_rate: number; + }; +} + +export interface TaxProfile { + id: string; + tax_year: number; + tax_code: string; + employer_name: string | null; + is_cumulative: boolean; + created_at: string; + updated_at: string; +} + +export interface TaxProfileCreate { + tax_code?: string; + employer_name?: string | null; + is_cumulative?: boolean; +} + +export interface Payslip { + id: string; + tax_profile_id: string; + period_month: number | null; + period_year: number; + gross_pay: string; + income_tax_withheld: string; + ni_withheld: string; + net_pay: string; + is_p60: boolean; + notes: string | null; + created_at: string; +} + +export interface PayslipCreate { + period_month?: number | null; + period_year: number; + gross_pay: number; + income_tax_withheld: number; + ni_withheld: number; + net_pay: number; + notes?: string | null; +} + +export interface P60Entry { + gross_pay: number; + income_tax_withheld: number; + ni_withheld: number; + net_pay: number; +} + +export interface ManualDisposal { + id: string; + tax_year: number; + disposal_date: string; + asset_description: string; + proceeds: string; + cost_basis: string; + gain_loss: string; + notes: string | null; + created_at: string; +} + +export interface ManualDisposalCreate { + disposal_date: string; + asset_description: string; + proceeds: number; + cost_basis: number; + notes?: string | null; +} + +// --------------------------------------------------------------------------- +// Tax report nested types +// --------------------------------------------------------------------------- + +export interface BandBreakdown { + rate: number; + taxable: number; + tax: number; + from?: number; + to?: number | null; +} + +export interface InvestmentDisposalItem { + date: string; + asset: string; + symbol: string; + quantity: string; + proceeds: string; + cost_basis: string; + fees: string; + gain_loss: string; +} + +export interface DividendTransactionItem { + date: string; + asset: string; + symbol: string; + amount: string; +} + +export interface TaxReport { + tax_year: number; + tax_year_display: string; + profile: TaxProfile | null; + income: { + gross_income: string; + income_tax_withheld: string; + ni_withheld: string; + payslips: Payslip[]; + }; + income_tax: { + personal_allowance: string; + taxable_income: string; + liability: string; + band_breakdown: BandBreakdown[]; + withheld: string; + owed: string; + }; + ni: { + liability: string; + band_breakdown: BandBreakdown[]; + withheld: string; + owed: string; + }; + cgt: { + gross_gain: string; + exempt: string; + taxable_gain: string; + liability: string; + band_breakdown: BandBreakdown[]; + investment_disposals: InvestmentDisposalItem[]; + manual_disposals: ManualDisposal[]; + total_gain: string; + }; + dividends: { + gross_dividends: string; + allowance: string; + taxable_dividends: string; + liability: string; + band_breakdown: BandBreakdown[]; + dividend_transactions: DividendTransactionItem[]; + }; + summary: { + total_liability: string; + total_withheld: string; + net_owed: string; + overpaid: boolean; + }; +} + +// --------------------------------------------------------------------------- +// API functions — rate configs +// --------------------------------------------------------------------------- + +export async function getConfiguredYears(): Promise { + const res = await api.get("/tax/rate-configs"); + return res.data; +} + +export async function getRateConfig(taxYear: number): Promise { + const res = await api.get(`/tax/rate-configs/${taxYear}`); + return res.data; +} + +export async function upsertRateConfig( + taxYear: number, + data: TaxRateConfigUpdate +): Promise { + const res = await api.put(`/tax/rate-configs/${taxYear}`, data); + return res.data; +} + +// --------------------------------------------------------------------------- +// API functions — tax profile +// --------------------------------------------------------------------------- + +export async function getTaxProfile(taxYear: number): Promise { + const res = await api.get(`/tax/profile/${taxYear}`); + return res.data; +} + +export async function upsertTaxProfile( + taxYear: number, + data: TaxProfileCreate +): Promise { + const res = await api.put(`/tax/profile/${taxYear}`, data); + return res.data; +} + +// --------------------------------------------------------------------------- +// API functions — payslips +// --------------------------------------------------------------------------- + +export async function getPayslips(taxYear: number): Promise { + const res = await api.get(`/tax/payslips/${taxYear}`); + return res.data; +} + +export async function createPayslip( + taxYear: number, + data: PayslipCreate +): Promise { + const res = await api.post(`/tax/payslips/${taxYear}`, data); + return res.data; +} + +export async function updatePayslip( + id: string, + data: Partial +): Promise { + const res = await api.put(`/tax/payslips/${id}`, data); + return res.data; +} + +export async function deletePayslip(id: string): Promise { + await api.delete(`/tax/payslips/${id}`); +} + +export async function enterP60(taxYear: number, data: P60Entry): Promise { + await api.post(`/tax/payslips/${taxYear}/p60`, data); +} + +// --------------------------------------------------------------------------- +// API functions — manual CGT disposals +// --------------------------------------------------------------------------- + +export async function getCgtDisposals(taxYear: number): Promise { + const res = await api.get(`/tax/cgt-disposals/${taxYear}`); + return res.data; +} + +export async function createCgtDisposal( + taxYear: number, + data: ManualDisposalCreate +): Promise { + const res = await api.post(`/tax/cgt-disposals/${taxYear}`, data); + return res.data; +} + +export async function updateCgtDisposal( + id: string, + data: Partial +): Promise { + const res = await api.put(`/tax/cgt-disposals/${id}`, data); + return res.data; +} + +export async function deleteCgtDisposal(id: string): Promise { + await api.delete(`/tax/cgt-disposals/${id}`); +} + +// --------------------------------------------------------------------------- +// API functions — report +// --------------------------------------------------------------------------- + +export async function getTaxReport(taxYear: number): Promise { + const res = await api.get(`/tax/report/${taxYear}`); + return res.data; +} diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts index b8ac710..95e66f2 100644 --- a/frontend/src/api/transactions.ts +++ b/frontend/src/api/transactions.ts @@ -25,6 +25,7 @@ export interface Transaction { notes: string | null; tags: string[]; is_recurring: boolean; + recurring_rule: Record | null; attachment_refs: AttachmentRef[]; created_at: string; updated_at: string; @@ -43,6 +44,8 @@ export interface TransactionCreate { merchant?: string; notes?: string; tags?: string[]; + is_recurring?: boolean; + recurring_rule?: Record | null; } export interface TransactionPage { diff --git a/frontend/src/components/layout/MobileNav.tsx b/frontend/src/components/layout/MobileNav.tsx index 56d098e..b99e97c 100644 --- a/frontend/src/components/layout/MobileNav.tsx +++ b/frontend/src/components/layout/MobileNav.tsx @@ -2,18 +2,20 @@ import { Link, useLocation } from "react-router-dom"; import { cn } from "@/utils/cn"; import { LayoutDashboard, CreditCard, ArrowLeftRight, - PiggyBank, TrendingUp, BarChart3, Sparkles, Settings, + PiggyBank, TrendingUp, BarChart3, Sparkles, Settings, Repeat, Receipt, } from "lucide-react"; const NAV = [ - { href: "/", icon: LayoutDashboard, label: "Home" }, - { href: "/accounts", icon: CreditCard, label: "Accounts" }, - { href: "/transactions",icon: ArrowLeftRight, label: "Txns" }, - { href: "/budgets", icon: PiggyBank, label: "Budgets" }, - { href: "/investments", icon: TrendingUp, label: "Invest" }, - { href: "/reports", icon: BarChart3, label: "Reports" }, - { href: "/predictions", icon: Sparkles, label: "Predict" }, - { href: "/settings", icon: Settings, label: "Settings" }, + { href: "/", icon: LayoutDashboard, label: "Home" }, + { href: "/accounts", icon: CreditCard, label: "Accounts" }, + { href: "/transactions", icon: ArrowLeftRight, label: "Txns" }, + { href: "/subscriptions",icon: Repeat, label: "Recurring" }, + { href: "/budgets", icon: PiggyBank, label: "Budgets" }, + { href: "/investments", icon: TrendingUp, label: "Invest" }, + { href: "/reports", icon: BarChart3, label: "Reports" }, + { href: "/tax", icon: Receipt, label: "Tax" }, + { href: "/predictions", icon: Sparkles, label: "Predict" }, + { href: "/settings", icon: Settings, label: "Settings" }, ]; export default function MobileNav() { diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index e890e82..35816a2 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -13,15 +13,19 @@ import { ChevronLeft, ChevronRight, Coins, + Repeat, + Receipt, } from "lucide-react"; const navItems = [ { href: "/", icon: LayoutDashboard, label: "Dashboard" }, { href: "/accounts", icon: CreditCard, label: "Accounts" }, { href: "/transactions", icon: ArrowLeftRight, label: "Transactions" }, + { href: "/subscriptions", icon: Repeat, label: "Subscriptions" }, { href: "/budgets", icon: PiggyBank, label: "Budgets" }, { href: "/investments", icon: TrendingUp, label: "Investments" }, { href: "/reports", icon: BarChart3, label: "Reports" }, + { href: "/tax", icon: Receipt, label: "Tax" }, { href: "/predictions", icon: Sparkles, label: "Predictions" }, { href: "/settings", icon: Settings, label: "Settings" }, ]; diff --git a/frontend/src/pages/dashboard/Dashboard.tsx b/frontend/src/pages/dashboard/Dashboard.tsx index ddaba24..2846bf5 100644 --- a/frontend/src/pages/dashboard/Dashboard.tsx +++ b/frontend/src/pages/dashboard/Dashboard.tsx @@ -18,6 +18,20 @@ import { Link } from "react-router-dom"; const COLORS = ["#6366f1","#22c55e","#f97316","#ec4899","#14b8a6","#f59e0b","#8b5cf6","#ef4444"]; +const TOOLTIP_STYLE = { + contentStyle: { + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + fontSize: "12px", + color: "hsl(var(--foreground))", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + }, + labelStyle: { color: "hsl(var(--foreground))", fontWeight: 500, marginBottom: "4px" }, + itemStyle: { color: "hsl(var(--muted-foreground))" }, + cursor: { fill: "hsl(var(--muted-foreground))", fillOpacity: 0.08 }, +}; + const TYPE_COLORS: Record = { income: "text-success", expense: "text-destructive", @@ -117,7 +131,7 @@ export default function Dashboard() { `£${(v/1000).toFixed(0)}k`} width={45} /> - formatCurrency(v, nwReport.base_currency)} /> + formatCurrency(v, nwReport.base_currency)} /> @@ -138,7 +152,7 @@ export default function Dashboard() { ({ month: p.month, income: Number(p.income), expenses: Number(p.expenses) }))}> `£${(v/1000).toFixed(0)}k`} width={45} /> - formatCurrency(v, "GBP")} /> + formatCurrency(v, "GBP")} /> @@ -166,7 +180,7 @@ export default function Dashboard() { cx="50%" cy="50%" innerRadius={42} outerRadius={65} dataKey="value" paddingAngle={2}> {catReport.items.slice(0,8).map((_, i) => )} - formatCurrency(v, "GBP")} /> + formatCurrency(v, "GBP")} />
diff --git a/frontend/src/pages/investments/PortfolioCharts.tsx b/frontend/src/pages/investments/PortfolioCharts.tsx index d496afd..9f5ee35 100644 --- a/frontend/src/pages/investments/PortfolioCharts.tsx +++ b/frontend/src/pages/investments/PortfolioCharts.tsx @@ -10,6 +10,20 @@ const COLORS = [ "#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444", ]; +const TOOLTIP_STYLE = { + contentStyle: { + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + fontSize: "12px", + color: "hsl(var(--foreground))", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + }, + labelStyle: { color: "hsl(var(--foreground))", fontWeight: 500, marginBottom: "4px" }, + itemStyle: { color: "hsl(var(--muted-foreground))" }, + cursor: { fill: "hsl(var(--muted-foreground))", fillOpacity: 0.08 }, +}; + const TYPE_COLORS: Record = { stock: "#6366f1", etf: "#22c55e", @@ -181,16 +195,11 @@ export function CostVsValueChart({ portfolio }: { portfolio: PortfolioSummary }) width={52} /> [ formatCurrency(value, portfolio.currency), name === "cost" ? "Cost basis" : "Current value", ]} - contentStyle={{ - background: "hsl(var(--card))", - border: "1px solid hsl(var(--border))", - borderRadius: "8px", - fontSize: "12px", - }} /> @@ -265,13 +274,8 @@ export function ReturnChart({ portfolio }: { portfolio: PortfolioSummary }) { /> [`${Number(value).toFixed(2)}%`, "Return"]} - contentStyle={{ - background: "hsl(var(--card))", - border: "1px solid hsl(var(--border))", - borderRadius: "8px", - fontSize: "12px", - }} /> `£${(v/1000).toFixed(0)}k`} /> - formatCurrency(v, data.base_currency)} /> + formatCurrency(v, data.base_currency)} /> @@ -290,7 +304,7 @@ function IncomeExpenseTab() { `£${(v/1000).toFixed(0)}k`} /> - formatCurrency(v, data.currency)} /> + formatCurrency(v, data.currency)} /> @@ -344,7 +358,7 @@ function CashFlowTab() { `£${(v/1000).toFixed(1)}k`} /> `£${(v/1000).toFixed(1)}k`} /> - formatCurrency(v, data.currency)} /> + formatCurrency(v, data.currency)} /> @@ -408,7 +422,7 @@ function SavingsRateTab() { `£${(v/1000).toFixed(0)}k`} /> `${v}%`} /> - name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} /> + name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} /> @@ -488,7 +502,7 @@ function CategoriesTab() { /> ))} - formatCurrency(v, data.currency)} /> + formatCurrency(v, data.currency)} />
@@ -583,7 +597,7 @@ function BudgetVsActualTab() { `£${v}`} /> - formatCurrency(v, data.currency)} /> + formatCurrency(v, data.currency)} /> @@ -617,7 +631,7 @@ function SpendingTrendsTab() { `£${v}`} /> - formatCurrency(v, data.currency)} /> + formatCurrency(v, data.currency)} /> {data.categories.slice(0, 8).map((cat, i) => ( @@ -677,7 +691,7 @@ function InvestmentsTab() { `£${(v/1000).toFixed(0)}k`} /> - formatCurrency(v, perf.currency)} /> + formatCurrency(v, perf.currency)} /> {holdingsData.map((entry, i) => ( = 0 ? "#22c55e" : "#ef4444"} /> diff --git a/frontend/src/pages/subscriptions/SubscriptionsPage.tsx b/frontend/src/pages/subscriptions/SubscriptionsPage.tsx new file mode 100644 index 0000000..f2ba7a8 --- /dev/null +++ b/frontend/src/pages/subscriptions/SubscriptionsPage.tsx @@ -0,0 +1,269 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { getSubscriptions, triggerDetection } from "@/api/subscriptions"; +import { updateTransaction } from "@/api/transactions"; +import type { Subscription } from "@/api/subscriptions"; +import { formatCurrency } from "@/utils/currency"; +import { cn } from "@/utils/cn"; +import { format, parseISO, differenceInDays } from "date-fns"; +import { + RefreshCw, Loader2, CalendarClock, AlertCircle, Ban, +} from "lucide-react"; + +type SortKey = "next" | "amount" | "name"; + +function nextBadge(nextExpected: string | null) { + if (!nextExpected) return null; + const days = differenceInDays(parseISO(nextExpected), new Date()); + + if (days < 0) { + return ( + + Overdue + + ); + } + if (days === 0) { + return ( + + Today + + ); + } + if (days <= 7) { + return ( + + {days}d + + ); + } + return ( + + {format(parseISO(nextExpected), "d MMM")} + + ); +} + +function FrequencyBadge({ frequency }: { frequency: string }) { + const labels: Record = { + weekly: "Weekly", + fortnightly: "Fortnightly", + monthly: "Monthly", + quarterly: "Quarterly", + yearly: "Yearly", + unknown: "Unknown", + }; + return ( + + {labels[frequency] ?? frequency} + + ); +} + +function SubscriptionRow({ + sub, + onUnmark, +}: { + sub: Subscription; + onUnmark: (id: string) => void; +}) { + const [menuOpen, setMenuOpen] = useState(false); + + return ( +
+ {/* Icon */} +
+ +
+ + {/* Name + account */} +
+

{sub.name}

+
+ + {sub.account_name && ( + {sub.account_name} + )} + {sub.manually_set && ( + manual + )} +
+
+ + {/* Last paid */} +
+

Last paid

+

{sub.last_paid ? format(parseISO(sub.last_paid), "d MMM yyyy") : "—"}

+
+ + {/* Next expected */} +
+

Next

+ {nextBadge(sub.next_expected)} +
+ + {/* Amount */} +
+

+ {formatCurrency(Math.abs(sub.amount), "GBP")} +

+

+ {formatCurrency(sub.monthly_equivalent, "GBP")}/mo +

+
+ + {/* Actions */} +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} /> +
+ +
+ + )} +
+
+ ); +} + +export default function SubscriptionsPage() { + const qc = useQueryClient(); + const [sort, setSort] = useState("next"); + const [rescanMsg, setRescanMsg] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ["subscriptions"], + queryFn: getSubscriptions, + }); + + const rescanMutation = useMutation({ + mutationFn: triggerDetection, + onSuccess: (result) => { + qc.invalidateQueries({ queryKey: ["subscriptions"] }); + qc.invalidateQueries({ queryKey: ["transactions"] }); + setRescanMsg(`Done — ${result.newly_tagged} newly tagged, ${result.total_recurring} total recurring.`); + setTimeout(() => setRescanMsg(null), 4000); + }, + }); + + const unmarkMutation = useMutation({ + mutationFn: (txnId: string) => + updateTransaction(txnId, { + is_recurring: false, + recurring_rule: { manually_set: true }, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["subscriptions"] }); + qc.invalidateQueries({ queryKey: ["transactions"] }); + }, + }); + + const sorted = [...(data?.subscriptions ?? [])].sort((a, b) => { + if (sort === "next") { + return (a.next_expected ?? "9999") < (b.next_expected ?? "9999") ? -1 : 1; + } + if (sort === "amount") return Math.abs(b.amount) - Math.abs(a.amount); + return a.name.localeCompare(b.name); + }); + + return ( +
+ {/* Header */} +
+
+

Subscriptions

+

+ Direct debits, standing orders, and recurring payments +

+
+ +
+ + {rescanMsg && ( +
+ {rescanMsg} +
+ )} + + {/* Summary card */} + {data && ( +
+

Estimated monthly recurring spend

+

+ {formatCurrency(data.total_monthly_equivalent, data.currency)} +

+

+ {data.subscriptions.length} recurring payment{data.subscriptions.length !== 1 ? "s" : ""} detected +

+
+ )} + + {/* Sort */} + {(data?.subscriptions.length ?? 0) > 0 && ( +
+ Sort: + {(["next", "amount", "name"] as SortKey[]).map((key) => ( + + ))} +
+ )} + + {/* List */} +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : sorted.length === 0 ? ( +
+ +

No recurring transactions detected

+

Import a few months of bank statements, then hit Re-scan.

+
+ ) : ( + sorted.map((sub) => ( + unmarkMutation.mutate(id)} + /> + )) + )} +
+
+ ); +} diff --git a/frontend/src/pages/tax/CGTSection.tsx b/frontend/src/pages/tax/CGTSection.tsx new file mode 100644 index 0000000..5522baf --- /dev/null +++ b/frontend/src/pages/tax/CGTSection.tsx @@ -0,0 +1,245 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Plus, Pencil, Trash2, Info } from "lucide-react"; +import { + TAX_QUERY_KEYS, + createCgtDisposal, + updateCgtDisposal, + deleteCgtDisposal, + type TaxReport, + type ManualDisposal, + type ManualDisposalCreate, +} from "@/api/tax"; +import ManualDisposalFormModal from "./ManualDisposalFormModal"; + +function gbp(v: string | number) { + return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); +} + +function fmtDate(iso: string) { + return new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +function pct(r: number) { + return `${(r * 100 % 1 === 0 ? (r * 100).toFixed(0) : (r * 100).toFixed(1))}%`; +} + +function GainCell({ v }: { v: string }) { + const n = Number(v); + return ( + 0 ? "text-green-500" : n < 0 ? "text-destructive" : ""}`}> + {gbp(v)} + + ); +} + +interface Props { + taxYear: number; + report: TaxReport; +} + +export default function CGTSection({ taxYear, report }: Props) { + const qc = useQueryClient(); + const cgt = report.cgt; + + const [showAdd, setShowAdd] = useState(false); + const [editing, setEditing] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + function invalidate() { + qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) }); + } + + const createMut = useMutation({ + mutationFn: (data: ManualDisposalCreate) => createCgtDisposal(taxYear, data), + onSuccess: () => { invalidate(); setShowAdd(false); }, + }); + + const updateMut = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + updateCgtDisposal(id, data), + onSuccess: () => { invalidate(); setEditing(null); }, + }); + + const deleteMut = useMutation({ + mutationFn: (id: string) => deleteCgtDisposal(id), + onSuccess: () => { invalidate(); setDeletingId(null); }, + }); + + return ( + <> +
+

Capital Gains Tax

+ + {/* Mid-year rate change note for 2024/25 */} + {taxYear === 2025 && ( +
+ + + 2024/25 note: The October 2024 Budget raised CGT rates mid-year (from 30 Oct 2024: 18% basic, 24% higher, up from 10%/20%). This tool uses a single set of rates for the whole year — disposals before and after 30 Oct may be blended. Verify any significant gains against HMRC guidance. + +
+ )} + + {/* Summary */} +
+ {[ + { label: "Gross Gain", value: cgt.gross_gain }, + { label: "Annual Exempt", value: cgt.exempt }, + { label: "Taxable Gain", value: cgt.taxable_gain }, + { label: "CGT Liability", value: cgt.liability, bold: true }, + ].map(({ label, value, bold }) => ( +
+

{label}

+

{gbp(value)}

+
+ ))} +
+ + {/* Band breakdown */} + {cgt.band_breakdown.length > 0 && ( +
+

Band Breakdown

+ + + + + + + + + + {cgt.band_breakdown.map((b, i) => ( + + + + + + ))} + +
RateTaxableTax
{pct(b.rate)}{gbp(String(b.taxable))}{gbp(String(b.tax))}
+
+ )} + + {/* Auto-detected disposals */} +
+

+ Investment Disposals (auto-detected) +

+ {cgt.investment_disposals.length === 0 ? ( +

No investment disposals detected for this tax year.

+ ) : ( +
+ + + + + + + + + + + + {cgt.investment_disposals.map((d, i) => ( + + + + + + + + ))} + +
DateAssetProceedsCost BasisGain / Loss
{fmtDate(d.date)} +

{d.asset}

+

{d.symbol} · {d.quantity} units

+
{gbp(d.proceeds)}{gbp(d.cost_basis)}
+
+ )} +
+ + {/* Manual disposals */} +
+
+

+ Manual Disposals +

+ +
+ + {cgt.manual_disposals.length === 0 ? ( +

No manual disposals — add any non-investment capital gains here (e.g. property).

+ ) : ( +
+ + + + + + + + + + + + + {cgt.manual_disposals.map(d => ( + + + + + + + + + ))} + +
DateAssetProceedsCostGain / LossActions
{fmtDate(d.disposal_date)}{d.asset_description}{gbp(d.proceeds)}{gbp(d.cost_basis)} + {deletingId === d.id ? ( + + Delete? + + + + ) : ( + + + + + )} +
+
+ )} +
+
+ + {showAdd && ( + setShowAdd(false)} + onSubmit={data => createMut.mutate(data)} + isLoading={createMut.isPending} + /> + )} + + {editing && ( + setEditing(null)} + onSubmit={data => updateMut.mutate({ id: editing.id, data })} + isLoading={updateMut.isPending} + /> + )} + + ); +} diff --git a/frontend/src/pages/tax/DividendSection.tsx b/frontend/src/pages/tax/DividendSection.tsx new file mode 100644 index 0000000..3da22f0 --- /dev/null +++ b/frontend/src/pages/tax/DividendSection.tsx @@ -0,0 +1,101 @@ +import { type TaxReport } from "@/api/tax"; + +function gbp(v: string) { + return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); +} + +function pct(r: number) { + return `${(r * 100 % 1 === 0 ? (r * 100).toFixed(0) : (r * 100).toFixed(1))}%`; +} + +function fmtDate(iso: string) { + return new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +interface Props { + report: TaxReport; +} + +export default function DividendSection({ report }: Props) { + const div = report.dividends; + + return ( +
+

Dividends

+ + {/* Summary */} +
+ {[ + { label: "Gross Dividends", value: div.gross_dividends }, + { label: "Allowance", value: div.allowance }, + { label: "Taxable", value: div.taxable_dividends }, + { label: "Liability", value: div.liability, bold: true }, + ].map(({ label, value, bold }) => ( +
+

{label}

+

{gbp(value)}

+
+ ))} +
+ + {/* Band breakdown */} + {div.band_breakdown.length > 0 && ( +
+

Band Breakdown

+ + + + + + + + + + {div.band_breakdown.map((b, i) => ( + + + + + + ))} + +
RateTaxableTax
{pct(b.rate)}{gbp(String(b.taxable))}{gbp(String(b.tax))}
+
+ )} + + {/* Dividend transactions */} +
+

+ Dividend Transactions (auto-detected) +

+ {div.dividend_transactions.length === 0 ? ( +

No dividend income detected for this tax year.

+ ) : ( +
+ + + + + + + + + + {div.dividend_transactions.map((t, i) => ( + + + + + + ))} + +
DateAssetAmount
{fmtDate(t.date)} +

{t.asset}

+

{t.symbol}

+
{gbp(t.amount)}
+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/tax/ManualDisposalFormModal.tsx b/frontend/src/pages/tax/ManualDisposalFormModal.tsx new file mode 100644 index 0000000..cbff42d --- /dev/null +++ b/frontend/src/pages/tax/ManualDisposalFormModal.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; +import { X, Loader2 } from "lucide-react"; +import { type ManualDisposal, type ManualDisposalCreate } from "@/api/tax"; + +interface Props { + disposal?: ManualDisposal; + onClose: () => void; + onSubmit: (data: ManualDisposalCreate) => void; + isLoading: boolean; +} + +export default function ManualDisposalFormModal({ disposal, onClose, onSubmit, isLoading }: Props) { + const isEdit = !!disposal; + + const [form, setForm] = useState({ + disposal_date: disposal?.disposal_date ?? "", + asset_description: disposal?.asset_description ?? "", + proceeds: disposal ? String(Number(disposal.proceeds)) : "", + cost_basis: disposal ? String(Number(disposal.cost_basis)) : "", + notes: disposal?.notes ?? "", + }); + const [error, setError] = useState(null); + + function set(key: string, value: string) { + setForm(f => ({ ...f, [key]: value })); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!form.disposal_date) { setError("Disposal date is required"); return; } + if (!form.asset_description.trim()) { setError("Asset description is required"); return; } + const proceeds = parseFloat(form.proceeds); + const cost = parseFloat(form.cost_basis); + if (isNaN(proceeds) || proceeds < 0) { setError("Enter valid proceeds"); return; } + if (isNaN(cost) || cost < 0) { setError("Enter valid cost basis"); return; } + setError(null); + onSubmit({ + disposal_date: form.disposal_date, + asset_description: form.asset_description.trim(), + proceeds, + cost_basis: cost, + notes: form.notes.trim() || null, + }); + } + + const inp = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; + + return ( +
+
+
+

{isEdit ? "Edit Disposal" : "Add Manual Disposal"}

+ +
+ + +
+
+ + set("disposal_date", e.target.value)} className={inp} /> +
+
+ + set("asset_description", e.target.value)} className={inp} placeholder="e.g. Rental property" /> +
+
+ +
+
+ + set("proceeds", e.target.value)} className={inp} placeholder="0.00" /> +
+
+ + set("cost_basis", e.target.value)} className={inp} placeholder="0.00" /> +
+
+ + {form.proceeds && form.cost_basis && !isNaN(parseFloat(form.proceeds)) && !isNaN(parseFloat(form.cost_basis)) && ( +

+ Gain / Loss:{" "} + = 0 ? "text-green-500" : "text-destructive"}> + {new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format( + parseFloat(form.proceeds) - parseFloat(form.cost_basis) + )} + +

+ )} + +
+ + set("notes", e.target.value)} className={inp} placeholder="Optional" /> +
+ + {error &&

{error}

} + +
+ + +
+ +
+
+ ); +} diff --git a/frontend/src/pages/tax/OverallLiabilityCard.tsx b/frontend/src/pages/tax/OverallLiabilityCard.tsx new file mode 100644 index 0000000..7c43b90 --- /dev/null +++ b/frontend/src/pages/tax/OverallLiabilityCard.tsx @@ -0,0 +1,77 @@ +import { type TaxReport } from "@/api/tax"; +import { taxYearDisplay } from "./TaxYearSelector"; + +function gbp(v: string) { + return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); +} + +interface Props { + report: TaxReport; +} + +export default function OverallLiabilityCard({ report }: Props) { + const s = report.summary; + const net = Number(s.net_owed); + const isZero = net === 0; + + const netColor = isZero + ? "text-muted-foreground" + : s.overpaid + ? "text-green-500" + : "text-yellow-500"; + + const netLabel = isZero ? "Balanced" : s.overpaid ? "Refund due" : "Additional tax owed"; + + const breakdown = [ + { label: "Income Tax", value: report.income_tax.liability }, + { label: "National Insurance", value: report.ni.liability }, + { label: "Capital Gains Tax", value: report.cgt.liability }, + { label: "Dividend Tax", value: report.dividends.liability }, + ]; + + return ( +
+

Overall Tax Position — {taxYearDisplay(report.tax_year)}

+ +
+ {/* Big numbers */} +
+
+

Total Liability

+

{gbp(s.total_liability)}

+
+
+

Already Withheld

+

{gbp(s.total_withheld)}

+
+
+

{netLabel}

+

{gbp(s.net_owed)}

+ {!isZero && ( +

+ {s.overpaid + ? "HMRC should refund this amount (verify via self-assessment or payroll)" + : "You may owe this via self-assessment — verify with HMRC"} +

+ )} +
+
+ + {/* Liability breakdown */} +
+

Breakdown

+ {breakdown.map(({ label, value }) => ( +
+ {label} + {gbp(value)} +
+ ))} +
+ Total + {gbp(s.total_liability)} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/tax/P60Modal.tsx b/frontend/src/pages/tax/P60Modal.tsx new file mode 100644 index 0000000..54b20f2 --- /dev/null +++ b/frontend/src/pages/tax/P60Modal.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { X, Loader2, AlertTriangle } from "lucide-react"; +import { type P60Entry } from "@/api/tax"; + +interface Props { + existingCount: number; + onClose: () => void; + onSubmit: (data: P60Entry) => void; + isLoading: boolean; +} + +export default function P60Modal({ existingCount, onClose, onSubmit, isLoading }: Props) { + const [form, setForm] = useState({ + gross_pay: "", + income_tax_withheld: "", + ni_withheld: "", + net_pay: "", + }); + const [confirmed, setConfirmed] = useState(false); + const [error, setError] = useState(null); + + function set(key: string, value: string) { + setForm(f => ({ ...f, [key]: value })); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (existingCount > 0 && !confirmed) { setError("Please confirm you want to replace existing payslips"); return; } + const gross = parseFloat(form.gross_pay); + const tax = parseFloat(form.income_tax_withheld); + const ni = parseFloat(form.ni_withheld); + const net = parseFloat(form.net_pay); + if (isNaN(gross) || gross < 0) { setError("Enter a valid gross pay"); return; } + if (isNaN(tax) || tax < 0) { setError("Enter a valid income tax"); return; } + if (isNaN(ni) || ni < 0) { setError("Enter a valid NI amount"); return; } + if (isNaN(net) || net < 0) { setError("Enter a valid net pay"); return; } + setError(null); + onSubmit({ gross_pay: gross, income_tax_withheld: tax, ni_withheld: ni, net_pay: net }); + } + + const inp = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; + + return ( +
+
+
+

Enter P60 Totals

+ +
+ +
+

+ Enter the year-end totals from your P60. These are cumulative figures for the full tax year. +

+ + {existingCount > 0 && ( +
+ + + This will permanently replace your {existingCount} existing payslip{existingCount !== 1 ? "s" : ""} for this tax year with a single P60 entry. + +
+ )} + +
+ + set("gross_pay", e.target.value)} className={inp} placeholder="0.00" /> +
+ +
+
+ + set("income_tax_withheld", e.target.value)} className={inp} placeholder="0.00" /> +
+
+ + set("ni_withheld", e.target.value)} className={inp} placeholder="0.00" /> +
+
+ +
+ + set("net_pay", e.target.value)} className={inp} placeholder="0.00" /> +
+ + {existingCount > 0 && ( + + )} + + {error &&

{error}

} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/tax/PayslipFormModal.tsx b/frontend/src/pages/tax/PayslipFormModal.tsx new file mode 100644 index 0000000..25e13c6 --- /dev/null +++ b/frontend/src/pages/tax/PayslipFormModal.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { X, Loader2 } from "lucide-react"; +import { type Payslip, type PayslipCreate } from "@/api/tax"; + +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +interface Props { + taxYear: number; + payslip?: Payslip; + onClose: () => void; + onSubmit: (data: PayslipCreate) => void; + isLoading: boolean; +} + +export default function PayslipFormModal({ taxYear, payslip, onClose, onSubmit, isLoading }: Props) { + const isEdit = !!payslip; + + const [form, setForm] = useState({ + period_month: payslip?.period_month ?? 4, + period_year: payslip?.period_year ?? taxYear - 1, + gross_pay: payslip ? payslip.gross_pay : "", + income_tax_withheld: payslip ? payslip.income_tax_withheld : "", + ni_withheld: payslip ? payslip.ni_withheld : "", + net_pay: payslip ? payslip.net_pay : "", + notes: payslip?.notes ?? "", + }); + + const [error, setError] = useState(null); + + function set(key: string, value: string | number) { + setForm(f => ({ ...f, [key]: value })); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const gross = parseFloat(form.gross_pay); + const tax = parseFloat(form.income_tax_withheld); + const ni = parseFloat(form.ni_withheld); + const net = parseFloat(form.net_pay); + if (isNaN(gross) || gross < 0) { setError("Enter a valid gross pay amount"); return; } + if (isNaN(tax) || tax < 0) { setError("Enter a valid income tax amount"); return; } + if (isNaN(ni) || ni < 0) { setError("Enter a valid NI amount"); return; } + if (isNaN(net) || net < 0) { setError("Enter a valid net pay amount"); return; } + setError(null); + onSubmit({ + period_month: Number(form.period_month), + period_year: Number(form.period_year), + gross_pay: gross, + income_tax_withheld: tax, + ni_withheld: ni, + net_pay: net, + notes: form.notes.trim() || null, + }); + } + + const inp = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; + + return ( +
+
+
+

{isEdit ? "Edit Payslip" : "Add Payslip"}

+ +
+ +
+
+
+ + +
+
+ + set("period_year", e.target.value)} + className={inp} + placeholder="2024" + /> +
+
+ +
+ + set("gross_pay", e.target.value)} + className={inp} + placeholder="0.00" + /> +
+ +
+
+ + set("income_tax_withheld", e.target.value)} + className={inp} + placeholder="0.00" + /> +
+
+ + set("ni_withheld", e.target.value)} + className={inp} + placeholder="0.00" + /> +
+
+ +
+ + set("net_pay", e.target.value)} + className={inp} + placeholder="0.00" + /> +
+ +
+ + set("notes", e.target.value)} + className={inp} + placeholder="Optional" + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/tax/PayslipTable.tsx b/frontend/src/pages/tax/PayslipTable.tsx new file mode 100644 index 0000000..c15e644 --- /dev/null +++ b/frontend/src/pages/tax/PayslipTable.tsx @@ -0,0 +1,233 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, FileText, Pencil, Trash2, Loader2 } from "lucide-react"; +import { + TAX_QUERY_KEYS, + getPayslips, + createPayslip, + updatePayslip, + deletePayslip, + enterP60, + type Payslip, + type PayslipCreate, + type P60Entry, +} from "@/api/tax"; +import PayslipFormModal from "./PayslipFormModal"; +import P60Modal from "./P60Modal"; + +const MONTH_ABBR = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + +function gbp(v: string) { + return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); +} + +function monthLabel(p: Payslip) { + if (p.is_p60) return "P60"; + if (p.period_month) return `${MONTH_ABBR[p.period_month - 1]} ${p.period_year}`; + return `${p.period_year}`; +} + +interface Props { + taxYear: number; +} + +export default function PayslipTable({ taxYear }: Props) { + const qc = useQueryClient(); + + const { data: payslips = [], isLoading } = useQuery({ + queryKey: TAX_QUERY_KEYS.payslips(taxYear), + queryFn: () => getPayslips(taxYear), + }); + + const [showAdd, setShowAdd] = useState(false); + const [editing, setEditing] = useState(null); + const [showP60, setShowP60] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + function invalidate() { + qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.payslips(taxYear) }); + qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) }); + } + + const createMut = useMutation({ + mutationFn: (data: PayslipCreate) => createPayslip(taxYear, data), + onSuccess: () => { invalidate(); setShowAdd(false); }, + }); + + const updateMut = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => updatePayslip(id, data), + onSuccess: () => { invalidate(); setEditing(null); }, + }); + + const deleteMut = useMutation({ + mutationFn: (id: string) => deletePayslip(id), + onSuccess: () => { invalidate(); setDeletingId(null); }, + }); + + const p60Mut = useMutation({ + mutationFn: (data: P60Entry) => enterP60(taxYear, data), + onSuccess: () => { invalidate(); setShowP60(false); }, + }); + + // Totals + const totals = payslips.reduce( + (acc, p) => ({ + gross: acc.gross + Number(p.gross_pay), + tax: acc.tax + Number(p.income_tax_withheld), + ni: acc.ni + Number(p.ni_withheld), + net: acc.net + Number(p.net_pay), + }), + { gross: 0, tax: 0, ni: 0, net: 0 } + ); + + function fmtNum(n: number) { + return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(n); + } + + return ( + <> +
+
+

Payslips

+
+ + +
+
+ + {isLoading ? ( +
+ + Loading… +
+ ) : payslips.length === 0 ? ( +
+ No payslips yet — add a payslip or enter P60 totals. +
+ ) : ( +
+ + + + + + + + + + + + + {payslips.map(p => ( + + + + + + + + + ))} + + {payslips.length > 1 && ( + + + + + + + + + + )} +
PeriodGrossIncome TaxNINet PayActions
+ {monthLabel(p)} + {p.is_p60 && ( + P60 + )} + {gbp(p.gross_pay)}{gbp(p.income_tax_withheld)}{gbp(p.ni_withheld)}{gbp(p.net_pay)} + {deletingId === p.id ? ( +
+ Delete? + + +
+ ) : ( +
+ {!p.is_p60 && ( + + )} + +
+ )} +
Total{fmtNum(totals.gross)}{fmtNum(totals.tax)}{fmtNum(totals.ni)}{fmtNum(totals.net)} +
+
+ )} +
+ + {showAdd && ( + setShowAdd(false)} + onSubmit={data => createMut.mutate(data)} + isLoading={createMut.isPending} + /> + )} + + {editing && ( + setEditing(null)} + onSubmit={data => updateMut.mutate({ id: editing.id, data })} + isLoading={updateMut.isPending} + /> + )} + + {showP60 && ( + setShowP60(false)} + onSubmit={data => p60Mut.mutate(data)} + isLoading={p60Mut.isPending} + /> + )} + + ); +} diff --git a/frontend/src/pages/tax/RateConfigModal.tsx b/frontend/src/pages/tax/RateConfigModal.tsx new file mode 100644 index 0000000..31bc30a --- /dev/null +++ b/frontend/src/pages/tax/RateConfigModal.tsx @@ -0,0 +1,440 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { X, Plus, Trash2, Loader2 } from "lucide-react"; +import { + TAX_QUERY_KEYS, + getRateConfig, + upsertRateConfig, + type TaxRateConfigUpdate, +} from "@/api/tax"; +import { taxYearDisplay } from "./TaxYearSelector"; + +// --------------------------------------------------------------------------- +// Types for local form state — all numeric values kept as strings for inputs +// --------------------------------------------------------------------------- + +type Tab = "income_tax" | "ni" | "cgt" | "dividend"; + +type BandRow = { from: string; to: string; rate: string }; + +type CgtForm = { exempt: string; basic_rate: string; higher_rate: string }; + +type DivForm = { + allowance: string; + basic_rate: string; + higher_rate: string; + additional_rate: string; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function pctIn(d: number) { + // Convert stored decimal (0.20) → display percentage string ("20") + const p = d * 100; + return p % 1 === 0 ? String(p) : p.toFixed(4).replace(/\.?0+$/, ""); +} + +function parseBands(rows: BandRow[]) { + return rows.map(r => ({ + from: parseFloat(r.from) || 0, + to: r.to.trim() === "" ? null : parseFloat(r.to), + rate: (parseFloat(r.rate) || 0) / 100, + })); +} + +// --------------------------------------------------------------------------- +// Band table sub-component +// --------------------------------------------------------------------------- + +function BandTable({ + bands, + onChange, +}: { + bands: BandRow[]; + onChange: (rows: BandRow[]) => void; +}) { + const inp = + "w-full rounded border border-input bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring tabular-nums"; + + function update(i: number, field: keyof BandRow, val: string) { + const next = bands.map((b, idx) => (idx === i ? { ...b, [field]: val } : b)); + onChange(next); + } + + function addRow() { + onChange([...bands, { from: "", to: "", rate: "" }]); + } + + function removeRow(i: number) { + onChange(bands.filter((_, idx) => idx !== i)); + } + + return ( +
+
+ + + + + + + + + + {bands.map((b, i) => ( + + + + + + + ))} + +
From (£)To (£, blank = unlimited)Rate (%) +
+ update(i, "from", e.target.value)} + className={inp} + placeholder="0" + /> + + update(i, "to", e.target.value)} + className={inp} + placeholder="—" + /> + + update(i, "rate", e.target.value)} + className={inp} + placeholder="0" + /> + + +
+
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// Main modal +// --------------------------------------------------------------------------- + +const TABS: { key: Tab; label: string }[] = [ + { key: "income_tax", label: "Income Tax" }, + { key: "ni", label: "NI" }, + { key: "cgt", label: "CGT" }, + { key: "dividend", label: "Dividends" }, +]; + +interface Props { + taxYear: number; + onClose: () => void; +} + +export default function RateConfigModal({ taxYear, onClose }: Props) { + const qc = useQueryClient(); + const [tab, setTab] = useState("income_tax"); + + const [itBands, setItBands] = useState([]); + const [niBands, setNiBands] = useState([]); + const [cgt, setCgt] = useState({ exempt: "", basic_rate: "", higher_rate: "" }); + const [div, setDiv] = useState({ + allowance: "", + basic_rate: "", + higher_rate: "", + additional_rate: "", + }); + const [error, setError] = useState(null); + + const { data: config, isLoading } = useQuery({ + queryKey: TAX_QUERY_KEYS.rateConfig(taxYear), + queryFn: () => getRateConfig(taxYear), + }); + + // Populate form state once config loads + useEffect(() => { + if (!config) return; + const r = config.rates; + + if (r.income_tax?.bands) { + setItBands( + r.income_tax.bands.map(b => ({ + from: String(b.from), + to: b.to === null ? "" : String(b.to), + rate: pctIn(b.rate), + })) + ); + } + + if (r.ni?.bands) { + setNiBands( + r.ni.bands.map(b => ({ + from: String(b.from), + to: b.to === null ? "" : String(b.to), + rate: pctIn(b.rate), + })) + ); + } + + if (r.cgt) { + setCgt({ + exempt: String(r.cgt.exempt), + basic_rate: pctIn(r.cgt.basic_rate), + higher_rate: pctIn(r.cgt.higher_rate), + }); + } + + if (r.dividend) { + setDiv({ + allowance: String(r.dividend.allowance), + basic_rate: pctIn(r.dividend.basic_rate), + higher_rate: pctIn(r.dividend.higher_rate), + additional_rate: pctIn(r.dividend.additional_rate), + }); + } + }, [config]); + + const saveMut = useMutation({ + mutationFn: (data: TaxRateConfigUpdate) => upsertRateConfig(taxYear, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.rateConfig(taxYear) }); + qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) }); + onClose(); + }, + onError: (err: unknown) => { + setError((err as Error)?.message ?? "Failed to save"); + }, + }); + + function handleSave() { + setError(null); + const payload: TaxRateConfigUpdate = { + income_tax: itBands.length > 0 ? { bands: parseBands(itBands) } : undefined, + ni: niBands.length > 0 ? { bands: parseBands(niBands) } : undefined, + cgt: { + exempt: parseFloat(cgt.exempt) || 0, + basic_rate: (parseFloat(cgt.basic_rate) || 0) / 100, + higher_rate: (parseFloat(cgt.higher_rate) || 0) / 100, + }, + dividend: { + allowance: parseFloat(div.allowance) || 0, + basic_rate: (parseFloat(div.basic_rate) || 0) / 100, + higher_rate: (parseFloat(div.higher_rate) || 0) / 100, + additional_rate: (parseFloat(div.additional_rate) || 0) / 100, + }, + }; + saveMut.mutate(payload); + } + + const inp = + "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; + + return ( +
+
+ {/* Header */} +
+

+ Rate Configuration — {taxYearDisplay(taxYear)} +

+ +
+ + {/* Tabs */} +
+ {TABS.map(t => ( + + ))} +
+ + {/* Body */} +
+ {isLoading ? ( +
+ + Loading… +
+ ) : ( + <> + {tab === "income_tax" && ( +
+

+ Bands are applied to gross income. The first band (0%) covers the personal + allowance; its upper limit is overridden by the tax code. +

+ +
+ )} + + {tab === "ni" && ( +
+

+ Primary Class 1 NI bands. Rates are applied to gross earnings. +

+ +
+ )} + + {tab === "cgt" && ( +
+
+ + setCgt(c => ({ ...c, exempt: e.target.value }))} + className={inp} + placeholder="3000" + /> +
+
+
+ + setCgt(c => ({ ...c, basic_rate: e.target.value }))} + className={inp} + placeholder="18" + /> +
+
+ + setCgt(c => ({ ...c, higher_rate: e.target.value }))} + className={inp} + placeholder="24" + /> +
+
+

+ Basic rate applies when remaining basic-rate band > 0; higher rate applies to gains above. +

+
+ )} + + {tab === "dividend" && ( +
+
+ + setDiv(d => ({ ...d, allowance: e.target.value }))} + className={inp} + placeholder="500" + /> +
+
+
+ + setDiv(d => ({ ...d, basic_rate: e.target.value }))} + className={inp} + placeholder="8.75" + /> +
+
+ + setDiv(d => ({ ...d, higher_rate: e.target.value }))} + className={inp} + placeholder="33.75" + /> +
+
+ + setDiv(d => ({ ...d, additional_rate: e.target.value }))} + className={inp} + placeholder="39.35" + /> +
+
+
+ )} + + )} +
+ + {/* Footer */} + {error && ( +

+ {error} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/tax/TaxNISummaryCard.tsx b/frontend/src/pages/tax/TaxNISummaryCard.tsx new file mode 100644 index 0000000..2121aec --- /dev/null +++ b/frontend/src/pages/tax/TaxNISummaryCard.tsx @@ -0,0 +1,99 @@ +import { type TaxReport, type BandBreakdown } from "@/api/tax"; + +function gbp(v: string) { + return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); +} + +function pct(r: number) { + return `${(r * 100 % 1 === 0 ? (r * 100).toFixed(0) : (r * 100).toFixed(1))}%`; +} + +function OwedRow({ owed, overpaid }: { owed: string; overpaid?: boolean }) { + const n = Number(owed); + const isZero = n === 0; + const color = isZero + ? "text-muted-foreground" + : overpaid !== undefined + ? overpaid ? "text-green-500" : "text-yellow-500" + : n < 0 ? "text-green-500" : "text-yellow-500"; + + return ( +
+ + {n < 0 || (n >= 0 && !isZero && overpaid) ? "Overpaid" : n === 0 ? "Balanced" : "Still owed"} + + {gbp(owed)} +
+ ); +} + +function Kv({ label, value, bold }: { label: string; value: string; bold?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +function BandTable({ bands }: { bands: BandBreakdown[] }) { + if (bands.length === 0) return

No taxable amount in this category.

; + return ( + + + + + + + + + + {bands.map((b, i) => ( + + + + + + ))} + +
RateTaxableTax
{pct(b.rate)}{gbp(String(b.taxable))}{gbp(String(b.tax))}
+ ); +} + +interface Props { + report: TaxReport; +} + +export default function TaxNISummaryCard({ report }: Props) { + const it = report.income_tax; + const ni = report.ni; + + return ( +
+

Income Tax & National Insurance

+ +
+ {/* Income Tax */} +
+

Income Tax

+ + + + + + + +
+ + {/* NI */} +
+

National Insurance

+ + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/tax/TaxPage.tsx b/frontend/src/pages/tax/TaxPage.tsx new file mode 100644 index 0000000..68b6599 --- /dev/null +++ b/frontend/src/pages/tax/TaxPage.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, Loader2, SlidersHorizontal } from "lucide-react"; + +import { TAX_QUERY_KEYS, getConfiguredYears, getTaxReport, type TaxReport } from "@/api/tax"; +import TaxYearSelector, { currentTaxYear, taxYearDisplay } from "./TaxYearSelector"; +import TaxProfileCard from "./TaxProfileCard"; +import PayslipTable from "./PayslipTable"; +import TaxNISummaryCard from "./TaxNISummaryCard"; +import CGTSection from "./CGTSection"; +import DividendSection from "./DividendSection"; +import OverallLiabilityCard from "./OverallLiabilityCard"; +import RateConfigModal from "./RateConfigModal"; + +// --------------------------------------------------------------------------- +// Loading skeleton +// --------------------------------------------------------------------------- + +function SkeletonBlock({ h = "h-6", w = "w-full" }: { h?: string; w?: string }) { + return
; +} + +function ReportSkeleton() { + return ( +
+ {/* at-a-glance */} +
+ +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ {/* profile + payslips */} +
+ +
+ {Array.from({ length: 3 }).map((_, i) => )} +
+
+
+ + +
+ {/* income tax + NI */} +
+ +
+
{Array.from({ length: 5 }).map((_, i) => )}
+
{Array.from({ length: 4 }).map((_, i) => )}
+
+
+ {/* CGT + dividends */} + {[48, 36].map(h => ( +
+ + +
+ ))} + {/* overall */} +
+ +
+ + + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Quick-summary card — shows top-level numbers from report while full UI is built +// --------------------------------------------------------------------------- + +function fmt(v: string) { + return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); +} + +function SummaryOverview({ report }: { report: TaxReport }) { + const items = [ + { label: "Gross income", value: fmt(report.income.gross_income) }, + { label: "Income tax", value: fmt(report.income_tax.liability) }, + { label: "National Insurance", value: fmt(report.ni.liability) }, + { label: "CGT", value: fmt(report.cgt.liability) }, + { label: "Dividend tax", value: fmt(report.dividends.liability) }, + { label: "Total liability", value: fmt(report.summary.total_liability), bold: true }, + { label: "Already withheld", value: fmt(report.summary.total_withheld) }, + { + label: report.summary.overpaid ? "Overpaid" : "Still owed", + value: fmt(report.summary.net_owed), + highlight: true, + overpaid: report.summary.overpaid, + }, + ]; + + return ( +
+

+ {taxYearDisplay(report.tax_year)} at a glance +

+
+ {items.map(({ label, value, bold, highlight, overpaid }) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- + +export default function TaxPage() { + const { data: years = [], isLoading: yearsLoading } = useQuery({ + queryKey: TAX_QUERY_KEYS.configuredYears, + queryFn: getConfiguredYears, + }); + + const [taxYear, setTaxYear] = useState(null); + const [showRates, setShowRates] = useState(false); + + // Once years load, pick the best default: current tax year if available, + // otherwise the highest configured year. + useEffect(() => { + if (years.length === 0) return; + const preferred = currentTaxYear(); + setTaxYear(years.includes(preferred) ? preferred : Math.max(...years)); + }, [years]); + + const { + data: report, + isLoading: reportLoading, + isError, + error, + } = useQuery({ + queryKey: TAX_QUERY_KEYS.report(taxYear ?? 0), + queryFn: () => getTaxReport(taxYear!), + enabled: taxYear !== null, + }); + + if (yearsLoading || taxYear === null) { + return ( +
+ + Loading tax data… +
+ ); + } + + if (years.length === 0) { + return ( +
+
+

No tax rate configuration found.

+

+ Run the database migration to seed default rates. +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Tax

+
+ + +
+
+ + {/* Disclaimer */} +
+ + Estimates only — not financial or tax advice. Always verify against HMRC. +
+ + {/* Report area */} + {reportLoading && } + + {isError && ( +
+ {(error as Error)?.message ?? "Failed to load tax report."} +
+ )} + + {report && ( + <> + + + + + + + + + )} + + {showRates && taxYear && ( + setShowRates(false)} /> + )} +
+ ); +} diff --git a/frontend/src/pages/tax/TaxProfileCard.tsx b/frontend/src/pages/tax/TaxProfileCard.tsx new file mode 100644 index 0000000..7fde7f7 --- /dev/null +++ b/frontend/src/pages/tax/TaxProfileCard.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Pencil, Check, X, Loader2 } from "lucide-react"; +import { + TAX_QUERY_KEYS, + getTaxProfile, + upsertTaxProfile, + type TaxProfileCreate, +} from "@/api/tax"; + +interface Props { + taxYear: number; +} + +export default function TaxProfileCard({ taxYear }: Props) { + const qc = useQueryClient(); + + const { data: profile, isLoading, isError, error } = useQuery({ + queryKey: TAX_QUERY_KEYS.profile(taxYear), + queryFn: () => getTaxProfile(taxYear), + retry: (_, err: unknown) => { + const status = (err as { response?: { status?: number } })?.response?.status; + return status !== 404; + }, + }); + + const upsertMut = useMutation({ + mutationFn: (data: TaxProfileCreate) => upsertTaxProfile(taxYear, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.profile(taxYear) }); + qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) }); + setEditing(false); + }, + }); + + const [editing, setEditing] = useState(false); + const [form, setForm] = useState({ tax_code: "", employer_name: "", is_cumulative: true }); + + function openEdit() { + setForm({ + tax_code: profile?.tax_code ?? "1257L", + employer_name: profile?.employer_name ?? "", + is_cumulative: profile?.is_cumulative ?? true, + }); + setEditing(true); + } + + function handleSave() { + upsertMut.mutate({ + tax_code: form.tax_code.trim() || "1257L", + employer_name: form.employer_name.trim() || null, + is_cumulative: form.is_cumulative, + }); + } + + const inp = "rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; + const is404 = (error as { response?: { status?: number } })?.response?.status === 404; + + return ( +
+
+

Tax Profile

+ {!editing && ( + + )} +
+ + {isLoading && ( +
+ + Loading… +
+ )} + + {!isLoading && !editing && ( + <> + {isError && !is404 && ( +

Failed to load profile.

+ )} + {(is404 || !profile) && ( +

+ No profile for this tax year.{" "} + {" "} + to improve estimate accuracy. +

+ )} + {profile && ( +
+
+
Tax Code
+
{profile.tax_code}
+
+
+
Employer
+
{profile.employer_name ?? "—"}
+
+
+
Mode
+
{profile.is_cumulative ? "Cumulative" : "Week 1 / Month 1"}
+
+
+ )} + + )} + + {editing && ( +
+
+
+ + setForm(f => ({ ...f, tax_code: e.target.value }))} + className={`${inp} w-full font-mono`} + placeholder="1257L" + /> +
+
+ + setForm(f => ({ ...f, employer_name: e.target.value }))} + className={`${inp} w-full`} + placeholder="Optional" + /> +
+
+ + + +
+ + +
+ + {upsertMut.isError && ( +

{(upsertMut.error as Error)?.message ?? "Failed to save"}

+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/tax/TaxYearSelector.tsx b/frontend/src/pages/tax/TaxYearSelector.tsx new file mode 100644 index 0000000..66f18cb --- /dev/null +++ b/frontend/src/pages/tax/TaxYearSelector.tsx @@ -0,0 +1,31 @@ +interface Props { + years: number[]; + value: number; + onChange: (year: number) => void; +} + +export function taxYearDisplay(year: number) { + return `${year - 1}/${String(year).slice(2)}`; +} + +export function currentTaxYear(): number { + const now = new Date(); + const y = now.getFullYear(); + return now >= new Date(y, 3, 6) ? y + 1 : y; +} + +export default function TaxYearSelector({ years, value, onChange }: Props) { + return ( + + ); +} diff --git a/frontend/src/pages/transactions/TransactionDetailDrawer.tsx b/frontend/src/pages/transactions/TransactionDetailDrawer.tsx index 01e7f75..b25c6f6 100644 --- a/frontend/src/pages/transactions/TransactionDetailDrawer.tsx +++ b/frontend/src/pages/transactions/TransactionDetailDrawer.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; import { X, Paperclip, Upload, Trash2, FileText, ImageIcon, Loader2, - ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Sparkles, CheckCircle, + ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Sparkles, CheckCircle, Repeat, } from "lucide-react"; import { cn } from "@/utils/cn"; import { formatCurrency } from "@/utils/currency"; @@ -59,6 +59,7 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate const [parseResult, setParseResult] = useState<{ attId: string; data: ParsedReceipt } | null>(null); const [parseError, setParseError] = useState(null); const [applySuccess, setApplySuccess] = useState(false); + const [isRecurring, setIsRecurring] = useState(transaction.is_recurring); const fileInputRef = useRef(null); const Icon = TYPE_ICONS[transaction.type] ?? ArrowDownCircle; @@ -110,6 +111,18 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate }, }); + const recurringMutation = useMutation({ + mutationFn: (value: boolean) => updateTransaction(transaction.id, { + is_recurring: value, + recurring_rule: { manually_set: true }, + }), + onSuccess: (_data, value) => { + setIsRecurring(value); + qc.invalidateQueries({ queryKey: ["transactions"] }); + qc.invalidateQueries({ queryKey: ["subscriptions"] }); + }, + }); + const handleFiles = useCallback((files: FileList | null) => { if (!files) return; setUploadError(null); @@ -196,6 +209,32 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate )}
+ {/* Recurring toggle */} +
+
+ +
+

Recurring payment

+

+ {isRecurring ? "Appears in Subscriptions" : "Not marked as recurring"} +

+
+
+ +
+ {/* Attachments */}
diff --git a/frontend/src/pages/transactions/TransactionList.tsx b/frontend/src/pages/transactions/TransactionList.tsx index 3be9dce..99542a3 100644 --- a/frontend/src/pages/transactions/TransactionList.tsx +++ b/frontend/src/pages/transactions/TransactionList.tsx @@ -10,7 +10,7 @@ import { cn } from "@/utils/cn"; import { format, startOfMonth, subMonths, startOfYear } from "date-fns"; import { Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload, - ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw, ScanLine, Loader2, + ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw, ScanLine, Loader2, Repeat, } from "lucide-react"; import TransactionFormModal from "./TransactionFormModal"; import TransactionDetailDrawer from "./TransactionDetailDrawer"; @@ -326,6 +326,11 @@ export default function TransactionList() {

{txn.description}

+ {txn.is_recurring && ( + + + + )} {txn.attachment_refs?.length > 0 && ( )} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 71ae8ea..a729993 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -36,7 +36,7 @@ const config: Config = { foreground: "hsl(var(--card-foreground))", }, success: { DEFAULT: "hsl(var(--success, 142 71% 45%))" }, - warning: { DEFAULT: "#f59e0b" }, + warning: { DEFAULT: "hsl(var(--warning, 38 92% 58%))" }, }, borderRadius: { lg: "var(--radius)",