# 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.