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

415 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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