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>
This commit is contained in:
parent
0b326cbd87
commit
afb5e99bb2
48 changed files with 6238 additions and 39 deletions
415
TAX_FEATURE_PLAN.md
Normal file
415
TAX_FEATURE_PLAN.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue