MyMidas/TAX_FEATURE_PLAN.md
megaproxy afb5e99bb2 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 <noreply@anthropic.com>
2026-04-23 21:40:02 +00:00

17 KiB
Raw Permalink Blame History

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 112; 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 / 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 UIRateConfigModal 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.