- 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 <noreply@anthropic.com>
17 KiB
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:
{
"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:
{
"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
}
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.
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).
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.
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/TaxRateConfigResponseTaxProfileCreate/TaxProfileResponsePayslipCreate/PayslipResponseP60Entry(gross_pay, income_tax_withheld, ni_withheld, net_pay)ManualDisposalCreate/ManualDisposalResponseTaxReportResponse
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_configsrows 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 Predictionsfrontend/src/components/layout/MobileNav.tsx— same positionfrontend/src/App.tsx— add/taxroute pointing toTaxPage
API client: frontend/src/api/tax.ts
Typed functions for all endpoints. Interfaces for:
TaxRateConfig,TaxRateConfigUpdateTaxProfile,TaxProfileCreatePayslip,PayslipCreate,P60EntryManualDisposal,ManualDisposalCreateTaxReport
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 stateTaxYearSelector.tsx— dropdown of configured yearsRateConfigModal.tsx— shows/edits the JSONB rate bands for the selected year (table of bands, editable inputs)TaxProfileCard.tsx— shows/edits tax code and employer namePayslipTable.tsx— list, add, edit, delete; "Enter P60" button with confirmation dialogPayslipFormModal.tsx— single payslip month formP60Modal.tsx— four-field form (gross, tax withheld, NI withheld, net) with warning dialogTaxNISummaryCard.tsx— computed liability vs withheld, owed/overpaid highlightedCGTSection.tsx— auto + manual disposal tables, summaryManualDisposalFormModal.tsxDividendSection.tsx— auto-pulled, summary onlyOverallLiabilityCard.tsx— final totals
Implementation Order
- Migration — create the four tables with RLS policies; seed 2025/2026 rate configs for existing users
- Tax calculation engine (
tax_service.py) — pure functions takingrates: dict, unit-testable without DB - Backend models (
db/models/tax.py) — SQLAlchemy mapped classes - Pydantic schemas (
schemas/tax.py) - Service layer — DB queries for CRUD +
load_rates()+build_tax_report() - API endpoints (
api/v1/tax.py) + register inrouter.py - Frontend API client (
api/tax.ts) - Nav wiring — add Tax to Sidebar, MobileNav, App.tsx routes
- Tax page UI — build components top-to-bottom following the layout above
- Rate config UI —
RateConfigModalso rates can be edited without touching code - 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_dateboundary (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_configsDB table. Pre-populated for 2025 and 2026. When a new Budget changes rates, the user edits them in-app viaRateConfigModal— 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 byteausingencrypt_field/decrypt_fieldfromcore/security.py. gain_lossnot stored: Computed in service asproceeds − 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 tocalculate_cgt(). - Sidebar placement: Tax sits between Reports and Predictions in both
Sidebar.tsxandMobileNav.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_profilesunique constraint is(user_id, tax_year)for now — relax to allow multiple rows per year when multi-employment is added.