diff --git a/README.md b/README.md index 4fb929b..25f4613 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w - Multiple account types: Checking, Savings, Cash ISA, Stocks & Shares ISA, Credit Card, Investment, Pension, Crypto Wallet, Loan, Mortgage, and more - Full transaction history with categories, tags, merchant tracking, and notes - Transfer detection between accounts -- **Recurring transaction detection** — CSV imports are automatically scanned for recurring payments (direct debits, subscriptions, standing orders) using frequency analysis; manually override any transaction +- Recurring transaction rules (rrule) - Receipt and document attachments on transactions (JPEG, PNG, WebP, PDF — up to 10 MB each) - **AI receipt scanning** — photograph a receipt to auto-extract merchant, amount, date, and description into a new transaction; receipt is automatically attached - CSV import with **auto-detection** for 10 UK bank formats: Monzo, Starling, Revolut, Barclays, Lloyds, NatWest, HSBC, Santander, Nationwide, and generic fallback @@ -36,31 +36,13 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w - Portfolio visualisations: allocation donut by holding, allocation donut by asset type, cost basis vs current value bar chart, return % per holding bar chart - Investment account balances include holding market values in net worth, balance sheet, and accounts list — supports both simple (holdings only) and full cash-flow tracking workflows -### Subscriptions -- Automatic grouping of recurring transactions into a subscriptions dashboard -- Monthly cost equivalent for each subscription (normalised across weekly/fortnightly/monthly/quarterly/yearly frequencies) -- Next payment date badges with overdue/upcoming highlighting -- Re-scan button to re-run detection after new imports -- Mark any transaction as recurring (or not) from the transaction detail view - +### Reports ### Categories - 45 built-in system categories across income, expense, and transfer types - Create custom categories with a name, type, and colour - Rename and recolour existing custom categories - Managed in **Settings → Categories** -### UK Tax Reporting -- Dedicated Tax page with per tax-year reports (tax_year=2026 = 2025/26) -- Enter monthly payslips or a single P60 annual figure -- Supports all HMRC tax codes: standard (1257L), BR, D0, D1, NT, K-codes, 0T, W1/M1 -- Calculates income tax liability with personal allowance taper above £100,000 -- NI Class 1 employee contributions -- Capital gains tax: auto-detected from investment disposals + manual entry for property/other assets -- Dividend tax: auto-detected from investment dividend transactions -- Shows liability vs. withheld (PAYE) and net owed/overpaid -- **Configurable rate tables** — edit tax bands in-app so Budget changes don't require a rebuild; pre-seeded for 2024/25 and 2025/26 (18%/24% CGT post Oct 2024 Budget) -- All figures are estimates for informational purposes only - ### Reports Seven report views: 1. Net Worth over time (area chart with time slider) diff --git a/RECURRING_TRANSACTIONS_PLAN.md b/RECURRING_TRANSACTIONS_PLAN.md deleted file mode 100644 index d0c6385..0000000 --- a/RECURRING_TRANSACTIONS_PLAN.md +++ /dev/null @@ -1,277 +0,0 @@ -# Recurring Transactions — Implementation Plan - -## Goal - -Automatically detect recurring transactions (direct debits, subscriptions, standing orders) from imported and existing transaction history. Tag them, predict next payment dates, and surface them in a dedicated Subscriptions page. All auto-detected values are user-correctable. - ---- - -## Scope - -- Fixed-amount recurring detection only (variable-amount bills excluded — too unreliable) -- Detection runs automatically after every CSV import and on manual trigger -- Minimum 2 occurrences to flag as recurring -- Supported frequencies: weekly, fortnightly, monthly, quarterly, yearly -- User can manually mark or unmark any transaction as recurring -- New Subscriptions page in nav + recurring indicator in transaction list - ---- - -## Detection Algorithm - -### Description normalisation - -UK bank CSV exports embed reference numbers, dates, and prefixes in descriptions that differ per occurrence but refer to the same payee. Normalise before grouping: - -1. Lowercase the full string -2. Strip known UK bank prefixes: `direct debit`, `dd `, `so `, `standing order`, `bacs`, `faster payment` -3. Strip trailing reference numbers: sequences of 6+ digits at end of string -4. Strip embedded date patterns (e.g. `15apr`, `15/04`) -5. Collapse multiple spaces, trim - -Examples: -``` -"DIRECT DEBIT NETFLIX 00123456" → "netflix" -"DD SPOTIFY AB 987654" → "spotify ab" -"NETFLIX.COM" → "netflix.com" -"COUNCIL TAX REF 20240415" → "council tax ref" -``` - -### Grouping and interval analysis - -Group all non-deleted transactions for a user by `(normalised_description, exact_amount)`. - -For each group with 2+ transactions: -1. Sort by date ascending -2. Calculate day-intervals between consecutive occurrences -3. Compute average interval -4. Classify against frequency table: - -| Frequency | Avg days range | Per-interval tolerance | -|---|---|---| -| Weekly | 6–8 | ±2 days | -| Fortnightly | 13–15 | ±3 days | -| Monthly | 26–35 | ±5 days | -| Quarterly | 85–95 | ±10 days | -| Yearly | 355–375 | ±15 days | - -5. Check all individual intervals fall within tolerance of the average -6. If matched: tag all transactions in the group - -### Confidence score - -`confidence = 1 - (std_dev_of_intervals / expected_interval)` - -Capped at 1.0. Stored in `recurring_rule` for potential future use (e.g. sorting by confidence). - -### Next expected date - -- **Weekly/fortnightly**: last_date + N days -- **Monthly**: last_date + 1 month (using `dateutil.relativedelta` — handles month-end correctly) -- **Quarterly**: last_date + 3 months -- **Yearly**: last_date + 1 year - -If next_expected is in the past (missed payment or detection ran late), advance by one frequency period until it's in the future. - ---- - -## Data Model - -No new tables required. Uses existing columns on `transactions`: - -``` -is_recurring: bool — already exists -recurring_rule: JSONB — already exists, was never populated -``` - -### `recurring_rule` JSONB shape - -```json -{ - "frequency": "monthly", - "typical_amount": -10.99, - "typical_day": 15, - "next_expected": "2026-05-15", - "confidence": 0.94, - "detected_at": "2026-04-23T10:00:00Z", - "manually_set": false -} -``` - -`manually_set: true` when the user explicitly toggled it — detection will not overwrite manually-set entries on subsequent re-scans. - ---- - -## Backend - -### Service: `backend/app/services/recurring_service.py` (new) - -```python -def normalise_description(raw: str) -> str - # Strips prefixes, refs, dates, lowercases - -def classify_frequency(avg_days: float) -> str | None - # Returns "weekly" | "fortnightly" | "monthly" | "quarterly" | "yearly" | None - -def next_expected_date(last_date: date, frequency: str) -> date - # Advances last_date by one frequency period; repeats until future date - -async def detect_recurring(db: AsyncSession, user_id: uuid.UUID) -> dict - # Loads all transactions, runs grouping + interval analysis - # Updates is_recurring and recurring_rule on matched transactions - # Skips transactions where recurring_rule.manually_set == true - # Returns {"newly_tagged": int, "total_recurring": int} -``` - -### Changes to `transaction_service.py` - -- `import_csv()`: call `detect_recurring(db, user_id)` after flush, before return -- `_to_response()`: ensure `recurring_rule` is included in the response dict (it currently isn't) - -### New endpoint: `POST /transactions/detect-recurring` - -Manual trigger. Calls `detect_recurring()` and returns the counts. Rate-limited — no need to spam it. - -### Changes to `GET /transactions` (existing) - -No changes needed. `is_recurring` filter already works. - -### New endpoint: `GET /subscriptions` - -Returns a grouped summary for the Subscriptions page: - -```json -{ - "total_monthly_equivalent": 87.43, - "currency": "GBP", - "subscriptions": [ - { - "name": "Netflix", - "amount": -10.99, - "frequency": "monthly", - "next_expected": "2026-05-03", - "last_paid": "2026-04-03", - "account_name": "Monzo", - "account_id": "...", - "transaction_ids": ["...", "..."], - "confidence": 0.98, - "manually_set": false - } - ] -} -``` - -Monthly equivalent conversion: -- Weekly × 52 / 12 -- Fortnightly × 26 / 12 -- Monthly × 1 -- Quarterly / 3 -- Yearly / 12 - -### Changes to `PUT /transactions/{id}` (existing) - -Already accepts `is_recurring`. Extend to also accept `recurring_rule` patch so the frontend can write `manually_set: true` when the user toggles. - ---- - -## Frontend - -### Transaction list — recurring indicator - -In `TransactionList.tsx`, next to the existing paperclip icon for attachments, add a `↻` (`RefreshCw`) icon for transactions where `is_recurring === true`. Small, muted, same style as the paperclip. - -### Transaction detail drawer — manual toggle - -In `TransactionDetailDrawer.tsx`, add a toggle in the detail panel: - -``` -[ ↻ ] Mark as recurring [toggle] -``` - -When toggled on manually: sets `is_recurring=true`, writes `recurring_rule: { manually_set: true, frequency: null, ... }` — opens a small inline form to let the user pick the frequency and confirm the amount. When toggled off: sets `is_recurring=false`, clears `recurring_rule`. - -### New page: `frontend/src/pages/subscriptions/SubscriptionsPage.tsx` - -#### Header - -``` -Subscriptions & Standing Orders -Estimated monthly spend: £87.43 [ Re-scan ] -``` - -Re-scan button calls `POST /transactions/detect-recurring`, invalidates the subscriptions query. - -#### Subscription cards / list - -Each row: -``` -[ icon ] Netflix £10.99 / month - Monzo · last paid 3 Apr Next: 3 May [3 days] [ ··· ] -``` - -Colour coding on the "Next" badge: -- Red: overdue (next_expected in the past) -- Amber: within 7 days -- Muted: more than 7 days away - -The `···` menu: "Mark as not recurring" (unsets it with `manually_set: true`). - -#### Sort options - -- Next payment (default) -- Amount (high to low) -- Name (A–Z) - -#### Empty state - -If no recurring transactions detected yet: prompt to import a CSV or use Re-scan. - -### Nav addition - -Add `Subscriptions` to the sidebar and mobile nav, between Transactions and Reports. Icon: `RefreshCw` or `Repeat`. - -### API client: `frontend/src/api/subscriptions.ts` (new) - -```typescript -export interface Subscription { ... } -export interface SubscriptionsSummary { ... } - -export const getSubscriptions = (): Promise => ... -export const triggerDetection = (): Promise<{ newly_tagged: number; total_recurring: number }> => ... -``` - ---- - -## Implementation Order - -1. **`recurring_service.py`** — detection logic, fully unit-testable without DB -2. **Wire into `import_csv()`** — auto-detect after every import -3. **`POST /transactions/detect-recurring`** — manual trigger endpoint -4. **`GET /subscriptions`** — grouped summary endpoint -5. **Update `_to_response()`** — include `recurring_rule` in transaction response -6. **`SubscriptionsPage.tsx` + `api/subscriptions.ts`** — main UI -7. **Transaction list `↻` indicator** — small icon addition -8. **Transaction detail drawer toggle** — manual mark/unmark - ---- - -## Testing Checkpoints - -- Normalisation: verify `"DIRECT DEBIT NETFLIX 00123456"` → `"netflix"` -- Monthly detection: 3 transactions, same amount, ~30 days apart → detected -- Below threshold: 1 occurrence → not detected -- Tolerance: transactions on 1st, 31st, 2nd of following months → monthly detected (within ±5 days) -- `manually_set: true` entries are not overwritten on re-scan -- Price change: Netflix at £10.99 (3 entries) + £11.99 (1 entry) → old entries stay tagged, new one not auto-tagged -- Monthly equivalent: yearly £120 subscription → shows £10.00/month - ---- - -## Notes & Decisions - -- **No new DB migration required** — `is_recurring` and `recurring_rule` columns already exist from the initial schema. -- **Detection is idempotent** — running it twice produces the same result. Safe to call on every import. -- **`manually_set` flag protects user corrections** — if a user untags something, a subsequent CSV import will not re-tag it. -- **`recurring_rule` not currently returned by `_to_response()`** — this must be added before the frontend can read frequency/next_expected. -- **Variable-amount recurrings** (energy, credit card payments) are out of scope. User can manually tag these if desired. -- **2-occurrence minimum** is configurable as a constant in `recurring_service.py` (`MIN_OCCURRENCES = 2`). diff --git a/TAX_FEATURE_PLAN.md b/TAX_FEATURE_PLAN.md deleted file mode 100644 index 555a4c4..0000000 --- a/TAX_FEATURE_PLAN.md +++ /dev/null @@ -1,415 +0,0 @@ -# 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. diff --git a/backend/alembic/versions/0006_add_tax_tables.py b/backend/alembic/versions/0006_add_tax_tables.py deleted file mode 100644 index 4645d16..0000000 --- a/backend/alembic/versions/0006_add_tax_tables.py +++ /dev/null @@ -1,186 +0,0 @@ -"""add tax tables - -Revision ID: 0006 -Revises: 0005 -Create Date: 2026-04-23 -""" -import uuid -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -revision = "0006" -down_revision = "0005" -branch_labels = None -depends_on = None - -# --------------------------------------------------------------------------- -# Seed data for 2025 and 2026 -# --------------------------------------------------------------------------- - -_INCOME_TAX_BANDS_2025 = { - "bands": [ - {"from": 0, "to": 12570, "rate": 0.00}, - {"from": 12570, "to": 50270, "rate": 0.20}, - {"from": 50270, "to": 125140, "rate": 0.40}, - {"from": 125140, "to": None, "rate": 0.45}, - ] -} -_NI_BANDS_2025 = { - "bands": [ - {"from": 0, "to": 12570, "rate": 0.00}, - {"from": 12570, "to": 50270, "rate": 0.08}, - {"from": 50270, "to": None, "rate": 0.02}, - ] -} -_CGT_2025 = {"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24} -_DIVIDEND_2025 = { - "allowance": 500, - "basic_rate": 0.0875, - "higher_rate": 0.3375, - "additional_rate": 0.3935, -} - -# 2026 thresholds remain frozen; rates unchanged from 2025 -_SEED = { - 2025: { - "income_tax": _INCOME_TAX_BANDS_2025, - "ni": _NI_BANDS_2025, - "cgt": _CGT_2025, - "dividend": _DIVIDEND_2025, - }, - 2026: { - "income_tax": _INCOME_TAX_BANDS_2025, - "ni": _NI_BANDS_2025, - "cgt": _CGT_2025, - "dividend": _DIVIDEND_2025, - }, -} - - -def upgrade() -> None: - # ------------------------------------------------------------------ - # tax_rate_configs - # ------------------------------------------------------------------ - op.create_table( - "tax_rate_configs", - sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), - sa.Column("user_id", postgresql.UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("tax_year", sa.Integer, nullable=False), - sa.Column("rate_type", sa.String(30), nullable=False), - sa.Column("config", postgresql.JSONB, nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), - ) - op.create_unique_constraint( - "uq_tax_rate_configs_user_year_type", - "tax_rate_configs", - ["user_id", "tax_year", "rate_type"], - ) - op.create_index("ix_tax_rate_configs_user_id", "tax_rate_configs", ["user_id"]) - - # ------------------------------------------------------------------ - # tax_profiles - # ------------------------------------------------------------------ - op.create_table( - "tax_profiles", - sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), - sa.Column("user_id", postgresql.UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("tax_year", sa.Integer, nullable=False), - sa.Column("employer_name_enc", sa.LargeBinary, nullable=True), - sa.Column("tax_code", sa.String(20), nullable=False, server_default="1257L"), - sa.Column("is_cumulative", sa.Boolean, nullable=False, server_default="true"), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), - ) - op.create_unique_constraint( - "uq_tax_profiles_user_year", - "tax_profiles", - ["user_id", "tax_year"], - ) - op.create_index("ix_tax_profiles_user_id", "tax_profiles", ["user_id"]) - - # ------------------------------------------------------------------ - # payslips - # ------------------------------------------------------------------ - op.create_table( - "payslips", - sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), - sa.Column("user_id", postgresql.UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("tax_profile_id", postgresql.UUID(as_uuid=True), - sa.ForeignKey("tax_profiles.id", ondelete="CASCADE"), nullable=False), - sa.Column("period_month", sa.SmallInteger, nullable=True), - sa.Column("period_year", sa.SmallInteger, nullable=False), - sa.Column("gross_pay", sa.Numeric(14, 2), nullable=False), - sa.Column("income_tax_withheld", sa.Numeric(14, 2), nullable=False), - sa.Column("ni_withheld", sa.Numeric(14, 2), nullable=False), - sa.Column("net_pay", sa.Numeric(14, 2), nullable=False), - sa.Column("is_p60", sa.Boolean, nullable=False, server_default="false"), - sa.Column("notes_enc", sa.LargeBinary, nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), - ) - op.create_index("ix_payslips_user_id", "payslips", ["user_id"]) - op.create_index("ix_payslips_tax_profile_id", "payslips", ["tax_profile_id"]) - - # ------------------------------------------------------------------ - # manual_cgt_disposals - # ------------------------------------------------------------------ - op.create_table( - "manual_cgt_disposals", - sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), - sa.Column("user_id", postgresql.UUID(as_uuid=True), - sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("tax_year", sa.Integer, nullable=False), - sa.Column("disposal_date", sa.Date, nullable=False), - sa.Column("asset_description_enc", sa.LargeBinary, nullable=False), - sa.Column("proceeds", sa.Numeric(14, 2), nullable=False), - sa.Column("cost_basis", sa.Numeric(14, 2), nullable=False), - sa.Column("notes_enc", sa.LargeBinary, nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), - ) - op.create_index("ix_manual_cgt_disposals_user_id", "manual_cgt_disposals", ["user_id"]) - - # ------------------------------------------------------------------ - # RLS - # ------------------------------------------------------------------ - for table in ["tax_rate_configs", "tax_profiles", "payslips", "manual_cgt_disposals"]: - op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") - op.execute(f""" - CREATE POLICY {table}_user_isolation ON {table} - USING (user_id = current_app_user_id()) - """) - - # ------------------------------------------------------------------ - # Seed 2025 + 2026 rate configs for all existing users - # ------------------------------------------------------------------ - import json - from datetime import datetime, timezone - now = datetime.now(timezone.utc).isoformat() - - for tax_year, rate_types in _SEED.items(): - for rate_type, config in rate_types.items(): - op.execute(sa.text(""" - INSERT INTO tax_rate_configs (id, user_id, tax_year, rate_type, config, updated_at) - SELECT gen_random_uuid(), id, :tax_year, :rate_type, CAST(:config AS jsonb), CAST(:updated_at AS timestamptz) - FROM users - WHERE deleted_at IS NULL - ON CONFLICT (user_id, tax_year, rate_type) DO NOTHING - """).bindparams( - tax_year=tax_year, - rate_type=rate_type, - config=json.dumps(config), - updated_at=now, - )) - - -def downgrade() -> None: - for table in ["tax_rate_configs", "tax_profiles", "payslips", "manual_cgt_disposals"]: - op.execute(f"DROP POLICY IF EXISTS {table}_user_isolation ON {table}") - op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") - - op.drop_table("manual_cgt_disposals") - op.drop_table("payslips") - op.drop_table("tax_profiles") - op.drop_table("tax_rate_configs") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 3ab6a71..85c5c1d 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin, settings, subscriptions, tax +from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin, settings router = APIRouter() router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -14,5 +14,3 @@ router.include_router(investments.router) router.include_router(predictions.router) router.include_router(admin.router) router.include_router(settings.router) -router.include_router(subscriptions.router) -router.include_router(tax.router) diff --git a/backend/app/api/v1/subscriptions.py b/backend/app/api/v1/subscriptions.py deleted file mode 100644 index 153230d..0000000 --- a/backend/app/api/v1/subscriptions.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -from collections import defaultdict -from decimal import Decimal - -from fastapi import APIRouter, Depends -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.security import decrypt_field -from app.db.models.account import Account -from app.db.models.transaction import Transaction -from app.dependencies import get_current_user, get_db - -router = APIRouter(prefix="/subscriptions", tags=["subscriptions"]) - -_MONTHLY_FACTORS = { - "weekly": Decimal("52") / Decimal("12"), - "fortnightly": Decimal("26") / Decimal("12"), - "monthly": Decimal("1"), - "quarterly": Decimal("1") / Decimal("3"), - "yearly": Decimal("1") / Decimal("12"), -} - - -@router.get("") -async def get_subscriptions( - db: AsyncSession = Depends(get_db), - user=Depends(get_current_user), -): - """Return all detected recurring transactions grouped as subscriptions.""" - - txn_result = await db.execute( - select(Transaction).where( - Transaction.user_id == user.id, - Transaction.is_recurring == True, - Transaction.deleted_at.is_(None), - ) - ) - transactions = txn_result.scalars().all() - - # Load accounts for name lookup - acc_result = await db.execute( - select(Account).where( - Account.user_id == user.id, - Account.deleted_at.is_(None), - ) - ) - account_map = {a.id: a for a in acc_result.scalars().all()} - - # Group by (normalised frequency+amount key from recurring_rule) - # Use (frequency, typical_amount, normalised_name) as the grouping key - # so manually-set entries with no rule still appear individually - from app.services.recurring_service import normalise_description - - # Group: key → list of transactions - groups: dict[str, list[Transaction]] = defaultdict(list) - for txn in transactions: - rule = txn.recurring_rule or {} - freq = rule.get("frequency", "unknown") - amt = rule.get("typical_amount", float(txn.amount)) - try: - desc = decrypt_field(txn.description_enc) or "" - except Exception: - desc = "" - norm = normalise_description(desc) - key = f"{norm}|{amt}|{freq}" - groups[key].append(txn) - - subscriptions = [] - total_monthly = Decimal("0") - - for key, txns in groups.items(): - # Use the transaction with the most recent date as the representative - txns_sorted = sorted(txns, key=lambda t: t.date, reverse=True) - latest = txns_sorted[0] - rule = latest.recurring_rule or {} - - freq = rule.get("frequency", "unknown") - amount = Decimal(str(rule.get("typical_amount", float(latest.amount)))) - next_expected = rule.get("next_expected") - last_paid = rule.get("last_paid") or str(latest.date) - confidence = rule.get("confidence", 1.0) - manually_set = rule.get("manually_set", False) - - try: - desc = decrypt_field(latest.description_enc) or "" - except Exception: - desc = "" - - account = account_map.get(latest.account_id) - try: - account_name = decrypt_field(account.name_enc) if account else None - except Exception: - account_name = None - - factor = _MONTHLY_FACTORS.get(freq, Decimal("1")) - monthly_equiv = abs(amount) * factor - total_monthly += monthly_equiv - - subscriptions.append({ - "name": desc, - "amount": float(amount), - "frequency": freq, - "next_expected": next_expected, - "last_paid": last_paid, - "account_id": str(latest.account_id), - "account_name": account_name, - "transaction_ids": [str(t.id) for t in txns], - "latest_transaction_id": str(latest.id), - "monthly_equivalent": float(monthly_equiv.quantize(Decimal("0.01"))), - "confidence": confidence, - "manually_set": manually_set, - }) - - # Sort by next_expected ascending (soonest first), nulls last - subscriptions.sort(key=lambda s: s["next_expected"] or "9999-99-99") - - return { - "total_monthly_equivalent": float(total_monthly.quantize(Decimal("0.01"))), - "currency": user.base_currency, - "subscriptions": subscriptions, - } diff --git a/backend/app/api/v1/tax.py b/backend/app/api/v1/tax.py deleted file mode 100644 index d3b2c67..0000000 --- a/backend/app/api/v1/tax.py +++ /dev/null @@ -1,293 +0,0 @@ -import uuid - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.dependencies import get_current_user, get_db -from app.db.models.user import User -from app.schemas.tax import ( - ManualDisposalCreate, - ManualDisposalResponse, - ManualDisposalUpdate, - P60Entry, - PayslipCreate, - PayslipResponse, - PayslipUpdate, - TaxProfileCreate, - TaxProfileResponse, - TaxRateConfigResponse, - TaxRateConfigUpdate, - TaxReportResponse, -) -from app.services import tax_service - -router = APIRouter(tags=["tax"]) - - -# --------------------------------------------------------------------------- -# Rate configs -# --------------------------------------------------------------------------- - -@router.get("/tax/rate-configs", response_model=list[int]) -async def list_rate_config_years( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - return await tax_service.list_configured_years(db, current_user.id) - - -@router.get("/tax/rate-configs/{tax_year}", response_model=TaxRateConfigResponse) -async def get_rate_config( - tax_year: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - try: - return await tax_service.get_rate_config(db, current_user.id, tax_year) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.put("/tax/rate-configs/{tax_year}", response_model=TaxRateConfigResponse) -async def upsert_rate_config( - tax_year: int, - data: TaxRateConfigUpdate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - rates = {k: v for k, v in data.model_dump().items() if v is not None} - if not rates: - raise HTTPException(status_code=422, detail="At least one rate type must be provided") - result = await tax_service.upsert_rate_config(db, current_user.id, tax_year, rates) - await db.commit() - return result - - -# --------------------------------------------------------------------------- -# Tax profile -# --------------------------------------------------------------------------- - -@router.get("/tax/profile/{tax_year}", response_model=TaxProfileResponse) -async def get_tax_profile( - tax_year: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - profile = await tax_service.get_tax_profile(db, current_user.id, tax_year) - if profile is None: - raise HTTPException(status_code=404, detail="No tax profile for this year") - return tax_service._profile_to_response(profile) - - -@router.put("/tax/profile/{tax_year}", response_model=TaxProfileResponse) -async def upsert_tax_profile( - tax_year: int, - data: TaxProfileCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - profile = await tax_service.upsert_tax_profile( - db, - current_user.id, - tax_year, - tax_code=data.tax_code, - employer_name=data.employer_name, - is_cumulative=data.is_cumulative, - ) - await db.commit() - return tax_service._profile_to_response(profile) - - -# --------------------------------------------------------------------------- -# Payslips -# --------------------------------------------------------------------------- - -@router.get("/tax/payslips/{tax_year}", response_model=list[PayslipResponse]) -async def list_payslips( - tax_year: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - payslips = await tax_service.list_payslips(db, current_user.id, tax_year) - return [tax_service._payslip_to_response(p) for p in payslips] - - -@router.post("/tax/payslips/{tax_year}", response_model=PayslipResponse, status_code=status.HTTP_201_CREATED) -async def create_payslip( - tax_year: int, - data: PayslipCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - try: - payslip = await tax_service.create_payslip( - db, - current_user.id, - tax_year, - period_month=data.period_month, - period_year=data.period_year, - gross_pay=data.gross_pay, - income_tax_withheld=data.income_tax_withheld, - ni_withheld=data.ni_withheld, - net_pay=data.net_pay, - notes=data.notes, - ) - await db.commit() - return tax_service._payslip_to_response(payslip) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.put("/tax/payslips/{payslip_id}", response_model=PayslipResponse) -async def update_payslip( - payslip_id: uuid.UUID, - data: PayslipUpdate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - try: - updates = {k: v for k, v in data.model_dump().items() if v is not None} - payslip = await tax_service.update_payslip(db, current_user.id, payslip_id, **updates) - await db.commit() - return tax_service._payslip_to_response(payslip) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.delete("/tax/payslips/{payslip_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_payslip( - payslip_id: uuid.UUID, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - try: - await tax_service.delete_payslip(db, current_user.id, payslip_id) - await db.commit() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.post("/tax/payslips/{tax_year}/p60", status_code=status.HTTP_204_NO_CONTENT) -async def enter_p60( - tax_year: int, - data: P60Entry, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - try: - await tax_service.replace_with_p60( - db, - current_user.id, - tax_year, - gross_pay=data.gross_pay, - income_tax_withheld=data.income_tax_withheld, - ni_withheld=data.ni_withheld, - net_pay=data.net_pay, - ) - await db.commit() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -# --------------------------------------------------------------------------- -# Manual CGT disposals -# --------------------------------------------------------------------------- - -@router.get("/tax/cgt-disposals/{tax_year}", response_model=list[ManualDisposalResponse]) -async def list_cgt_disposals( - tax_year: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - disposals = await tax_service.list_manual_disposals(db, current_user.id, tax_year) - return [tax_service._disposal_to_response(d) for d in disposals] - - -@router.post("/tax/cgt-disposals/{tax_year}", response_model=ManualDisposalResponse, status_code=status.HTTP_201_CREATED) -async def create_cgt_disposal( - tax_year: int, - data: ManualDisposalCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - disposal = await tax_service.create_manual_disposal( - db, - current_user.id, - tax_year, - disposal_date=data.disposal_date, - asset_description=data.asset_description, - proceeds=data.proceeds, - cost_basis=data.cost_basis, - notes=data.notes, - ) - await db.commit() - return tax_service._disposal_to_response(disposal) - - -@router.put("/tax/cgt-disposals/{disposal_id}", response_model=ManualDisposalResponse) -async def update_cgt_disposal( - disposal_id: uuid.UUID, - data: ManualDisposalUpdate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - from sqlalchemy import select - from app.db.models.tax import ManualCGTDisposal - from app.core.security import decrypt_field - - result = await db.execute( - select(ManualCGTDisposal).where( - ManualCGTDisposal.id == disposal_id, - ManualCGTDisposal.user_id == current_user.id, - ) - ) - disposal = result.scalar_one_or_none() - if disposal is None: - raise HTTPException(status_code=404, detail="Disposal not found") - - current_desc = decrypt_field(disposal.asset_description_enc) if disposal.asset_description_enc else "" - - try: - updated = await tax_service.update_manual_disposal( - db, - current_user.id, - disposal_id, - disposal_date=data.disposal_date or disposal.disposal_date, - asset_description=data.asset_description or current_desc, - proceeds=data.proceeds if data.proceeds is not None else disposal.proceeds, - cost_basis=data.cost_basis if data.cost_basis is not None else disposal.cost_basis, - notes=data.notes, - ) - await db.commit() - return tax_service._disposal_to_response(updated) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.delete("/tax/cgt-disposals/{disposal_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_cgt_disposal( - disposal_id: uuid.UUID, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - try: - await tax_service.delete_manual_disposal(db, current_user.id, disposal_id) - await db.commit() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -# --------------------------------------------------------------------------- -# Tax report -# --------------------------------------------------------------------------- - -@router.get("/tax/report/{tax_year}", response_model=TaxReportResponse) -async def get_tax_report( - tax_year: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - try: - return await tax_service.build_tax_report(db, current_user.id, tax_year) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) diff --git a/backend/app/api/v1/transactions.py b/backend/app/api/v1/transactions.py index 1fd167b..5929479 100644 --- a/backend/app/api/v1/transactions.py +++ b/backend/app/api/v1/transactions.py @@ -611,18 +611,6 @@ async def import_transactions( return result -@router.post("/detect-recurring") -async def detect_recurring_endpoint( - db: AsyncSession = Depends(get_db), - user=Depends(get_current_user), -): - """Manually trigger recurring transaction detection for the current user.""" - from app.services.recurring_service import detect_recurring - result = await detect_recurring(db, user.id) - await db.commit() - return result - - @router.get("/import/template") async def import_template(): from fastapi.responses import Response diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index 96b7ef4..3cbaf31 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -11,11 +11,9 @@ from app.db.models.investment_transaction import InvestmentTransaction from app.db.models.currency import Currency, ExchangeRate from app.db.models.net_worth_snapshot import NetWorthSnapshot from app.db.models.audit_log import AuditLog -from app.db.models.tax import TaxRateConfig, TaxProfile, Payslip, ManualCGTDisposal __all__ = [ "User", "Session", "Account", "Category", "Transaction", "Budget", "Asset", "AssetPrice", "InvestmentHolding", "InvestmentTransaction", "Currency", "ExchangeRate", "NetWorthSnapshot", "AuditLog", - "TaxRateConfig", "TaxProfile", "Payslip", "ManualCGTDisposal", ] diff --git a/backend/app/db/models/tax.py b/backend/app/db/models/tax.py deleted file mode 100644 index b323203..0000000 --- a/backend/app/db/models/tax.py +++ /dev/null @@ -1,74 +0,0 @@ -import uuid -from datetime import date, datetime -from decimal import Decimal - -from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, LargeBinary, Numeric, SmallInteger, String, UniqueConstraint -from sqlalchemy.dialects.postgresql import JSONB, UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.db.base import Base - - -class TaxRateConfig(Base): - __tablename__ = "tax_rate_configs" - __table_args__ = ( - UniqueConstraint("user_id", "tax_year", "rate_type", name="uq_tax_rate_configs_user_year_type"), - ) - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - tax_year: Mapped[int] = mapped_column(Integer, nullable=False) - rate_type: Mapped[str] = mapped_column(String(30), nullable=False) # income_tax|ni|cgt|dividend - config: Mapped[dict] = mapped_column(JSONB, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - - -class TaxProfile(Base): - __tablename__ = "tax_profiles" - __table_args__ = ( - UniqueConstraint("user_id", "tax_year", name="uq_tax_profiles_user_year"), - ) - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - tax_year: Mapped[int] = mapped_column(Integer, nullable=False) - employer_name_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) - tax_code: Mapped[str] = mapped_column(String(20), nullable=False, default="1257L") - is_cumulative: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - - payslips: Mapped[list["Payslip"]] = relationship(back_populates="tax_profile", lazy="noload") - - -class Payslip(Base): - __tablename__ = "payslips" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - tax_profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tax_profiles.id", ondelete="CASCADE"), nullable=False, index=True) - period_month: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) - period_year: Mapped[int] = mapped_column(SmallInteger, nullable=False) - gross_pay: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) - income_tax_withheld: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) - ni_withheld: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) - net_pay: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) - is_p60: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - notes_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - - tax_profile: Mapped["TaxProfile"] = relationship(back_populates="payslips", lazy="noload") - - -class ManualCGTDisposal(Base): - __tablename__ = "manual_cgt_disposals" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - tax_year: Mapped[int] = mapped_column(Integer, nullable=False) - disposal_date: Mapped[date] = mapped_column(Date, nullable=False) - asset_description_enc: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) - proceeds: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) - cost_basis: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) - notes_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/backend/app/schemas/tax.py b/backend/app/schemas/tax.py deleted file mode 100644 index a715fac..0000000 --- a/backend/app/schemas/tax.py +++ /dev/null @@ -1,215 +0,0 @@ -import uuid -from datetime import date as DateType, datetime -from decimal import Decimal -from typing import Any - -from pydantic import BaseModel, Field - - -# --------------------------------------------------------------------------- -# Tax rate config -# --------------------------------------------------------------------------- - -class TaxRateConfigUpdate(BaseModel): - """PUT /tax/rate-configs/{tax_year} — pass only the rate types you want to upsert.""" - income_tax: dict[str, Any] | None = None - ni: dict[str, Any] | None = None - cgt: dict[str, Any] | None = None - dividend: dict[str, Any] | None = None - - -class TaxRateConfigResponse(BaseModel): - tax_year: int - rates: dict[str, Any] - updated_at: str - - -# --------------------------------------------------------------------------- -# Tax profile -# --------------------------------------------------------------------------- - -class TaxProfileCreate(BaseModel): - tax_code: str = Field(default="1257L", min_length=1, max_length=20) - employer_name: str | None = Field(default=None, max_length=200) - is_cumulative: bool = True - - -class TaxProfileResponse(BaseModel): - id: uuid.UUID - tax_year: int - tax_code: str - employer_name: str | None - is_cumulative: bool - created_at: str - updated_at: str - - -# --------------------------------------------------------------------------- -# Payslips -# --------------------------------------------------------------------------- - -class PayslipCreate(BaseModel): - period_month: int | None = Field(default=None, ge=1, le=12) - period_year: int = Field(..., ge=2000, le=2100) - gross_pay: Decimal = Field(..., ge=0) - income_tax_withheld: Decimal = Field(..., ge=0) - ni_withheld: Decimal = Field(..., ge=0) - net_pay: Decimal = Field(..., ge=0) - notes: str | None = None - - -class PayslipUpdate(BaseModel): - period_month: int | None = Field(default=None, ge=1, le=12) - period_year: int | None = Field(default=None, ge=2000, le=2100) - gross_pay: Decimal | None = Field(default=None, ge=0) - income_tax_withheld: Decimal | None = Field(default=None, ge=0) - ni_withheld: Decimal | None = Field(default=None, ge=0) - net_pay: Decimal | None = Field(default=None, ge=0) - notes: str | None = None - - -class PayslipResponse(BaseModel): - id: uuid.UUID - tax_profile_id: uuid.UUID - period_month: int | None - period_year: int - gross_pay: str - income_tax_withheld: str - ni_withheld: str - net_pay: str - is_p60: bool - notes: str | None - created_at: str - - -class P60Entry(BaseModel): - gross_pay: Decimal = Field(..., ge=0) - income_tax_withheld: Decimal = Field(..., ge=0) - ni_withheld: Decimal = Field(..., ge=0) - net_pay: Decimal = Field(..., ge=0) - - -# --------------------------------------------------------------------------- -# Manual CGT disposals -# --------------------------------------------------------------------------- - -class ManualDisposalCreate(BaseModel): - disposal_date: DateType - asset_description: str = Field(..., min_length=1, max_length=500) - proceeds: Decimal = Field(..., ge=0) - cost_basis: Decimal = Field(..., ge=0) - notes: str | None = None - - -class ManualDisposalUpdate(BaseModel): - disposal_date: DateType | None = None - asset_description: str | None = Field(default=None, min_length=1, max_length=500) - proceeds: Decimal | None = Field(default=None, ge=0) - cost_basis: Decimal | None = Field(default=None, ge=0) - notes: str | None = None - - -class ManualDisposalResponse(BaseModel): - id: uuid.UUID - tax_year: int - disposal_date: str - asset_description: str - proceeds: str - cost_basis: str - gain_loss: str - notes: str | None - created_at: str - - -# --------------------------------------------------------------------------- -# Tax report (nested) -# --------------------------------------------------------------------------- - -class BandBreakdownItem(BaseModel): - rate: float - taxable: float - tax: float - from_: int | None = Field(default=None, alias="from") - to: int | None = None - - model_config = {"populate_by_name": True} - - -class IncomeTaxSummary(BaseModel): - personal_allowance: str - taxable_income: str - liability: str - band_breakdown: list[dict[str, Any]] - withheld: str - owed: str - - -class NISummary(BaseModel): - liability: str - band_breakdown: list[dict[str, Any]] - withheld: str - owed: str - - -class InvestmentDisposalItem(BaseModel): - date: str - asset: str - symbol: str - quantity: str - proceeds: str - cost_basis: str - fees: str - gain_loss: str - - -class CGTSummary(BaseModel): - gross_gain: str - exempt: str - taxable_gain: str - liability: str - band_breakdown: list[dict[str, Any]] - investment_disposals: list[dict[str, Any]] - manual_disposals: list[dict[str, Any]] - total_gain: str - - -class DividendTransactionItem(BaseModel): - date: str - asset: str - symbol: str - amount: str - - -class DividendSummary(BaseModel): - gross_dividends: str - allowance: str - taxable_dividends: str - liability: str - band_breakdown: list[dict[str, Any]] - dividend_transactions: list[dict[str, Any]] - - -class TaxReportSummary(BaseModel): - total_liability: str - total_withheld: str - net_owed: str - overpaid: bool - - -class IncomeSummary(BaseModel): - gross_income: str - income_tax_withheld: str - ni_withheld: str - payslips: list[dict[str, Any]] - - -class TaxReportResponse(BaseModel): - tax_year: int - tax_year_display: str - profile: dict[str, Any] | None - income: IncomeSummary - income_tax: IncomeTaxSummary - ni: NISummary - cgt: CGTSummary - dividends: DividendSummary - summary: TaxReportSummary diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py index a5c01a3..5d10087 100644 --- a/backend/app/schemas/transaction.py +++ b/backend/app/schemas/transaction.py @@ -35,8 +35,6 @@ class TransactionUpdate(BaseModel): merchant: str | None = None notes: str | None = None tags: list[str] | None = None - is_recurring: bool | None = None - recurring_rule: dict | None = None class TransactionFilter(BaseModel): @@ -73,7 +71,6 @@ class TransactionResponse(BaseModel): notes: str | None tags: list[str] is_recurring: bool - recurring_rule: dict | None = None attachment_refs: list[dict] = [] created_at: datetime updated_at: datetime diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 29a4acb..269acfe 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -85,10 +85,6 @@ async def register_user(db: AsyncSession, email: str, password: str, display_nam ) db.add(user) await db.flush() - - from app.services.tax_service import seed_default_rates - await seed_default_rates(db, user.id) - return user diff --git a/backend/app/services/recurring_service.py b/backend/app/services/recurring_service.py deleted file mode 100644 index f231d0b..0000000 --- a/backend/app/services/recurring_service.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -import re -import uuid -from collections import defaultdict -from datetime import date, datetime, timezone -from decimal import Decimal -from statistics import mean, stdev - -from dateutil.relativedelta import relativedelta -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.security import decrypt_field -from app.db.models.transaction import Transaction - -MIN_OCCURRENCES = 2 - -# (label, min_days, max_days, tolerance_days) -_FREQUENCIES = [ - ("weekly", 6, 8, 2), - ("fortnightly", 13, 15, 3), - ("monthly", 26, 35, 5), - ("quarterly", 85, 95, 10), - ("yearly", 355, 375, 15), -] - -_STRIP_PREFIXES = re.compile( - r"^(direct debit|standing order|faster payment|bacs|dd|so)\s+", - re.IGNORECASE, -) -_STRIP_REFS = re.compile(r"\s+\d{5,}$") -_STRIP_DATE_PATTERNS = re.compile( - r"\b\d{1,2}[/-]\d{1,2}([/-]\d{2,4})?\b" - r"|\b\d{1,2}(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b", - re.IGNORECASE, -) -_COLLAPSE_SPACES = re.compile(r"\s{2,}") - - -def normalise_description(raw: str) -> str: - s = raw.lower().strip() - s = _STRIP_PREFIXES.sub("", s) - s = _STRIP_DATE_PATTERNS.sub("", s) - s = _STRIP_REFS.sub("", s) - s = _COLLAPSE_SPACES.sub(" ", s).strip() - return s - - -def classify_frequency(avg_days: float) -> tuple[str, int] | None: - """Return (label, expected_days) or None if avg_days matches no known frequency.""" - for label, lo, hi, _ in _FREQUENCIES: - if lo <= avg_days <= hi: - return label, round(avg_days) - return None - - -def _within_tolerance(intervals: list[int], avg: float, frequency: str) -> bool: - tolerance = next(t for label, _, _, t in _FREQUENCIES if label == frequency) - return all(abs(iv - avg) <= tolerance for iv in intervals) - - -def next_expected_date(last: date, frequency: str) -> date: - delta_map = { - "weekly": relativedelta(weeks=1), - "fortnightly": relativedelta(weeks=2), - "monthly": relativedelta(months=1), - "quarterly": relativedelta(months=3), - "yearly": relativedelta(years=1), - } - delta = delta_map[frequency] - result = last + delta - today = date.today() - while result < today: - result += delta - return result - - -def _confidence(intervals: list[int], expected: int) -> float: - if len(intervals) < 2: - return 1.0 - try: - sd = stdev(intervals) - except Exception: - sd = 0.0 - conf = 1.0 - (sd / expected) if expected > 0 else 0.0 - return round(max(0.0, min(1.0, conf)), 4) - - -async def detect_recurring(db: AsyncSession, user_id: uuid.UUID) -> dict: - """ - Scan all transactions for a user, detect recurring patterns, and tag them. - Skips transactions where recurring_rule.manually_set == true. - Returns {"newly_tagged": int, "total_recurring": int}. - """ - result = await db.execute( - select(Transaction).where( - Transaction.user_id == user_id, - Transaction.deleted_at.is_(None), - ) - ) - transactions = result.scalars().all() - txn_map: dict[uuid.UUID, Transaction] = {t.id: t for t in transactions} - - # Group by (normalised_description, exact_amount) - keyed: dict[tuple[str, Decimal], tuple[list[date], list[uuid.UUID]]] = defaultdict( - lambda: ([], []) - ) - for txn in transactions: - try: - desc = decrypt_field(txn.description_enc) or "" - except Exception: - desc = "" - norm = normalise_description(desc) - amount = txn.amount.quantize(Decimal("0.01")) - dates_list, ids_list = keyed[(norm, amount)] - dates_list.append(txn.date) - ids_list.append(txn.id) - - now = datetime.now(timezone.utc) - newly_tagged = 0 - total_recurring = 0 - matched_ids: set[uuid.UUID] = set() - - for (norm_desc, amount), (dates_list, ids_list) in keyed.items(): - if len(dates_list) < MIN_OCCURRENCES: - continue - - paired = sorted(zip(dates_list, ids_list), key=lambda x: x[0]) - sorted_dates = [p[0] for p in paired] - sorted_ids = [p[1] for p in paired] - - intervals = [ - (sorted_dates[i + 1] - sorted_dates[i]).days - for i in range(len(sorted_dates) - 1) - ] - avg = mean(intervals) - - freq_result = classify_frequency(avg) - if freq_result is None: - continue - frequency, expected_days = freq_result - - if not _within_tolerance(intervals, avg, frequency): - continue - - conf = _confidence(intervals, expected_days) - last_date = sorted_dates[-1] - next_date = next_expected_date(last_date, frequency) - - for txn_id in sorted_ids: - txn = txn_map.get(txn_id) - if txn is None: - continue - - matched_ids.add(txn_id) - - rule = txn.recurring_rule or {} - if rule.get("manually_set"): - if txn.is_recurring: - total_recurring += 1 - continue - - was_recurring = txn.is_recurring - txn.is_recurring = True - txn.recurring_rule = { - "frequency": frequency, - "typical_amount": float(amount), - "next_expected": next_date.isoformat(), - "last_paid": last_date.isoformat(), - "confidence": conf, - "detected_at": now.isoformat(), - "manually_set": False, - } - txn.updated_at = now - - if not was_recurring: - newly_tagged += 1 - total_recurring += 1 - - # Un-tag previously auto-detected transactions whose pattern no longer matches - for txn in transactions: - if not txn.is_recurring: - continue - rule = txn.recurring_rule or {} - if rule.get("manually_set"): - continue - if txn.id not in matched_ids: - txn.is_recurring = False - txn.recurring_rule = None - txn.updated_at = now - - await db.flush() - return {"newly_tagged": newly_tagged, "total_recurring": total_recurring} diff --git a/backend/app/services/tax_calculations.py b/backend/app/services/tax_calculations.py deleted file mode 100644 index 9b47e78..0000000 --- a/backend/app/services/tax_calculations.py +++ /dev/null @@ -1,301 +0,0 @@ -""" -Pure UK tax calculation functions. Zero external dependencies — no DB, no ORM. -Each function receives a pre-loaded `rates` dict so they are fully unit-testable. - -Tax year convention: tax_year=N means 6 Apr (N-1) → 5 Apr N. -""" -from __future__ import annotations - -import re -from datetime import date -from decimal import ROUND_HALF_UP, Decimal -from typing import Any - - -# --------------------------------------------------------------------------- -# Tax year helpers -# --------------------------------------------------------------------------- - -def tax_year_for_date(d: date) -> int: - """Return tax_year int for a calendar date. tax_year=N = 6 Apr (N-1) → 5 Apr N.""" - if (d.month, d.day) >= (4, 6): - return d.year + 1 - return d.year - - -def tax_year_date_range(tax_year: int) -> tuple[date, date]: - """Return (start_date, end_date) inclusive for the given tax year.""" - return date(tax_year - 1, 4, 6), date(tax_year, 4, 5) - - -# --------------------------------------------------------------------------- -# Tax code parser -# --------------------------------------------------------------------------- - -def parse_tax_code(code: str) -> dict[str, Any]: - """Parse a UK tax code string. - - Returns: - allowance — annual personal allowance in £ (negative for K codes) - rate_override — flat rate (0.0–1.0) if code fixes a single rate, else None - k_code — True if K prefix (negative allowance) - no_tax — True if NT code - """ - raw = code.strip().upper() - raw = re.sub(r"[/\s]?(W1|M1)$", "", raw) - - if raw == "NT": - return {"allowance": Decimal("0"), "rate_override": Decimal("0"), "k_code": False, "no_tax": True} - if raw == "BR": - return {"allowance": Decimal("0"), "rate_override": Decimal("0.20"), "k_code": False, "no_tax": False} - if raw == "D0": - return {"allowance": Decimal("0"), "rate_override": Decimal("0.40"), "k_code": False, "no_tax": False} - if raw == "D1": - return {"allowance": Decimal("0"), "rate_override": Decimal("0.45"), "k_code": False, "no_tax": False} - if raw == "0T": - return {"allowance": Decimal("0"), "rate_override": None, "k_code": False, "no_tax": False} - - k_match = re.fullmatch(r"K(\d+)", raw) - if k_match: - return {"allowance": -Decimal(k_match.group(1)) * 10, "rate_override": None, "k_code": True, "no_tax": False} - - std_match = re.fullmatch(r"(\d+)[LMNTY]?", raw) - if std_match: - return {"allowance": Decimal(std_match.group(1)) * 10, "rate_override": None, "k_code": False, "no_tax": False} - - # Unknown code — treat as 0T - return {"allowance": Decimal("0"), "rate_override": None, "k_code": False, "no_tax": False} - - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - -def _apply_bands(amount: Decimal, bands: list[dict]) -> tuple[Decimal, list[dict]]: - total = Decimal("0") - breakdown = [] - for band in bands: - band_from = Decimal(str(band["from"])) - band_to = Decimal(str(band["to"])) if band["to"] is not None else None - rate = Decimal(str(band["rate"])) - - if amount <= band_from: - break - - upper = min(amount, band_to) if band_to is not None else amount - taxable_in_band = upper - band_from - tax_in_band = (taxable_in_band * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - total += tax_in_band - - if taxable_in_band > 0: - breakdown.append({ - "from": int(band_from), - "to": int(band_to) if band_to is not None else None, - "rate": float(rate), - "taxable": float(taxable_in_band), - "tax": float(tax_in_band), - }) - - return total, breakdown - - -def _personal_allowance_tapered(base_allowance: Decimal, gross_income: Decimal) -> Decimal: - """Reduce PA by £1 per £2 over £100,000; floor at zero at £125,140.""" - taper_threshold = Decimal("100000") - if gross_income <= taper_threshold: - return base_allowance - reduction = ((gross_income - taper_threshold) / 2).quantize(Decimal("1"), rounding=ROUND_HALF_UP) - return max(Decimal("0"), base_allowance - reduction) - - -# --------------------------------------------------------------------------- -# Core calculation functions -# --------------------------------------------------------------------------- - -def calculate_income_tax( - gross_income: Decimal, - tax_code: str, - rates: dict, -) -> dict[str, Any]: - """Calculate income tax liability and remaining basic-rate band. - - Bands are applied to GROSS income. The 0% band threshold is adjusted to match - the actual personal allowance from the tax code (with taper applied if applicable). - K codes add their amount to gross income before applying the standard bands. - - Returns: - personal_allowance, taxable_income, liability, band_breakdown, - remaining_basic_rate_band (passed downstream to CGT/dividend calculations) - """ - parsed = parse_tax_code(tax_code) - bands = rates["income_tax"]["bands"] - # These are gross-income thresholds from the band definitions - pa_threshold = Decimal(str(next(b["to"] for b in bands if b["rate"] == 0.00))) - basic_rate_upper = Decimal(str(next(b["to"] for b in bands if b["rate"] == 0.20))) - - if parsed["no_tax"]: - return { - "personal_allowance": pa_threshold, - "taxable_income": Decimal("0"), - "liability": Decimal("0"), - "band_breakdown": [], - "remaining_basic_rate_band": max(Decimal("0"), basic_rate_upper - gross_income), - } - - if parsed["rate_override"] is not None: - liability = (gross_income * parsed["rate_override"]).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - return { - "personal_allowance": Decimal("0"), - "taxable_income": gross_income, - "liability": liability, - "band_breakdown": [{"rate": float(parsed["rate_override"]), "tax": float(liability)}], - "remaining_basic_rate_band": Decimal("0"), - } - - if parsed["k_code"]: - # K codes: the K amount adds to taxable base; standard PA band still applies to effective gross. - # effective_gross is the "notional income" HMRC uses to calculate tax. - k_amount = abs(parsed["allowance"]) - effective_gross = gross_income + k_amount - personal_allowance = Decimal("0") # K code replaces any standard PA grant - taxable_income = effective_gross # reported as the effective taxable base - liability, band_breakdown = _apply_bands(effective_gross, bands) - remaining_brb = max(Decimal("0"), basic_rate_upper - effective_gross) - else: - base_pa = parsed["allowance"] - personal_allowance = _personal_allowance_tapered(base_pa, gross_income) if base_pa > 0 else base_pa - - # Adjust the 0% band to match the actual personal allowance, then apply to gross income. - adjusted_bands = [ - {"from": 0, "to": float(personal_allowance), "rate": 0.00} if b["rate"] == 0.00 else b - for b in bands - ] - taxable_income = max(Decimal("0"), gross_income - personal_allowance) - liability, band_breakdown = _apply_bands(gross_income, adjusted_bands) - remaining_brb = max(Decimal("0"), basic_rate_upper - gross_income) - - return { - "personal_allowance": personal_allowance, - "taxable_income": taxable_income, - "liability": liability, - "band_breakdown": band_breakdown, - "remaining_basic_rate_band": remaining_brb, - } - - -def calculate_ni(gross_income: Decimal, rates: dict) -> dict[str, Any]: - """Calculate primary Class 1 NI liability.""" - bands = rates["ni"]["bands"] - liability, band_breakdown = _apply_bands(gross_income, bands) - return {"liability": liability, "band_breakdown": band_breakdown} - - -def calculate_cgt( - total_gain: Decimal, - remaining_basic_rate_band: Decimal, - rates: dict, -) -> dict[str, Any]: - """Calculate CGT liability. - - Gains within the remaining basic-rate band are taxed at basic_rate; - gains above it at higher_rate. Annual exempt amount applied first. - """ - cgt_rates = rates["cgt"] - exempt = Decimal(str(cgt_rates["exempt"])) - basic_rate = Decimal(str(cgt_rates["basic_rate"])) - higher_rate = Decimal(str(cgt_rates["higher_rate"])) - - taxable_gain = max(Decimal("0"), total_gain - exempt) - - if taxable_gain == 0: - return { - "gross_gain": total_gain, - "exempt": min(total_gain, exempt) if total_gain > 0 else Decimal("0"), - "taxable_gain": Decimal("0"), - "liability": Decimal("0"), - "band_breakdown": [], - } - - basic_portion = min(taxable_gain, remaining_basic_rate_band) - higher_portion = taxable_gain - basic_portion - - liability = ( - (basic_portion * basic_rate) + (higher_portion * higher_rate) - ).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - - breakdown = [] - if basic_portion > 0: - breakdown.append({"rate": float(basic_rate), "taxable": float(basic_portion), - "tax": float((basic_portion * basic_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))}) - if higher_portion > 0: - breakdown.append({"rate": float(higher_rate), "taxable": float(higher_portion), - "tax": float((higher_portion * higher_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))}) - - return { - "gross_gain": total_gain, - "exempt": min(total_gain, exempt), - "taxable_gain": taxable_gain, - "liability": liability, - "band_breakdown": breakdown, - } - - -def calculate_dividend_tax( - total_dividends: Decimal, - remaining_basic_rate_band: Decimal, - rates: dict, -) -> dict[str, Any]: - """Calculate dividend tax liability. - - Dividend allowance applied first; taxable dividends are then slotted into - the remaining income bands to determine which rate applies. - """ - div_rates = rates["dividend"] - allowance = Decimal(str(div_rates["allowance"])) - basic_rate = Decimal(str(div_rates["basic_rate"])) - higher_rate = Decimal(str(div_rates["higher_rate"])) - additional_rate = Decimal(str(div_rates["additional_rate"])) - - taxable_dividends = max(Decimal("0"), total_dividends - allowance) - - if taxable_dividends == 0: - return { - "gross_dividends": total_dividends, - "allowance": min(total_dividends, allowance), - "taxable_dividends": Decimal("0"), - "liability": Decimal("0"), - "band_breakdown": [], - } - - basic_portion = min(taxable_dividends, remaining_basic_rate_band) - remainder = taxable_dividends - basic_portion - higher_upper = Decimal(str( - next(b["to"] for b in rates["income_tax"]["bands"] if b["rate"] == 0.40) - )) - higher_portion = min(remainder, higher_upper) - additional_portion = remainder - higher_portion - - liability = ( - (basic_portion * basic_rate) - + (higher_portion * higher_rate) - + (additional_portion * additional_rate) - ).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - - breakdown = [] - if basic_portion > 0: - breakdown.append({"rate": float(basic_rate), "taxable": float(basic_portion), - "tax": float((basic_portion * basic_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))}) - if higher_portion > 0: - breakdown.append({"rate": float(higher_rate), "taxable": float(higher_portion), - "tax": float((higher_portion * higher_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))}) - if additional_portion > 0: - breakdown.append({"rate": float(additional_rate), "taxable": float(additional_portion), - "tax": float((additional_portion * additional_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))}) - - return { - "gross_dividends": total_dividends, - "allowance": min(total_dividends, allowance), - "taxable_dividends": taxable_dividends, - "liability": liability, - "band_breakdown": breakdown, - } diff --git a/backend/app/services/tax_service.py b/backend/app/services/tax_service.py deleted file mode 100644 index 4d69a1d..0000000 --- a/backend/app/services/tax_service.py +++ /dev/null @@ -1,744 +0,0 @@ -""" -UK tax service layer for MyMidas. - -Pure calculation functions live in tax_calculations.py (no DB imports). -This module provides the DB-backed service layer: rate loading, CRUD, report builder. -""" -from __future__ import annotations - -import uuid -from datetime import date, datetime, timezone -from decimal import Decimal -from typing import Any - -from sqlalchemy import delete, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.security import decrypt_field, encrypt_field -from app.services.tax_calculations import ( # noqa: F401 — re-exported for callers - calculate_cgt, - calculate_dividend_tax, - calculate_income_tax, - calculate_ni, - parse_tax_code, - tax_year_date_range, - tax_year_for_date, -) - - -# --------------------------------------------------------------------------- -# DB helpers -# --------------------------------------------------------------------------- - -async def load_rates(db: AsyncSession, user_id: uuid.UUID, tax_year: int) -> dict: - """Load and return rate config dict for a given user/year. - - Returns {"income_tax": {...}, "ni": {...}, "cgt": {...}, "dividend": {...}} - Raises ValueError if any rate type is missing for the requested year. - """ - from app.db.models.tax import TaxRateConfig - result = await db.execute( - select(TaxRateConfig).where( - TaxRateConfig.user_id == user_id, - TaxRateConfig.tax_year == tax_year, - ) - ) - rows = list(result.scalars()) - if not rows: - raise ValueError(f"No tax rate config found for year {tax_year}. Please configure rates first.") - - rates: dict = {} - for row in rows: - rates[row.rate_type] = row.config - - required = {"income_tax", "ni", "cgt", "dividend"} - missing = required - rates.keys() - if missing: - raise ValueError(f"Incomplete tax rate config for year {tax_year}: missing {missing}") - - return rates - - -async def seed_default_rates(db: AsyncSession, user_id: uuid.UUID) -> None: - """Insert default 2025 and 2026 rate configs for a newly registered user.""" - from app.db.models.tax import TaxRateConfig - - now = datetime.now(timezone.utc) - - income_tax_bands = { - "bands": [ - {"from": 0, "to": 12570, "rate": 0.00}, - {"from": 12570, "to": 50270, "rate": 0.20}, - {"from": 50270, "to": 125140, "rate": 0.40}, - {"from": 125140, "to": None, "rate": 0.45}, - ] - } - ni_bands = { - "bands": [ - {"from": 0, "to": 12570, "rate": 0.00}, - {"from": 12570, "to": 50270, "rate": 0.08}, - {"from": 50270, "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, - } - - defaults = { - "income_tax": income_tax_bands, - "ni": ni_bands, - "cgt": cgt, - "dividend": dividend, - } - - for tax_year in (2025, 2026): - for rate_type, config in defaults.items(): - existing = await db.execute( - select(TaxRateConfig).where( - TaxRateConfig.user_id == user_id, - TaxRateConfig.tax_year == tax_year, - TaxRateConfig.rate_type == rate_type, - ) - ) - if existing.scalar_one_or_none() is None: - db.add(TaxRateConfig( - id=uuid.uuid4(), - user_id=user_id, - tax_year=tax_year, - rate_type=rate_type, - config=config, - updated_at=now, - )) - - -# --------------------------------------------------------------------------- -# Tax profile CRUD -# --------------------------------------------------------------------------- - -async def get_tax_profile( - db: AsyncSession, user_id: uuid.UUID, tax_year: int -): - from app.db.models.tax import TaxProfile - result = await db.execute( - select(TaxProfile).where( - TaxProfile.user_id == user_id, - TaxProfile.tax_year == tax_year, - ) - ) - return result.scalar_one_or_none() - - -async def upsert_tax_profile( - db: AsyncSession, - user_id: uuid.UUID, - tax_year: int, - tax_code: str, - employer_name: str | None, - is_cumulative: bool, -): - from app.db.models.tax import TaxProfile - from app.core.audit import write_audit - - now = datetime.now(timezone.utc) - profile = await get_tax_profile(db, user_id, tax_year) - - employer_enc = encrypt_field(employer_name) if employer_name else None - - if profile is None: - profile = TaxProfile( - id=uuid.uuid4(), - user_id=user_id, - tax_year=tax_year, - tax_code=tax_code, - employer_name_enc=employer_enc, - is_cumulative=is_cumulative, - created_at=now, - updated_at=now, - ) - db.add(profile) - action = "tax_profile_create" - else: - profile.tax_code = tax_code - profile.employer_name_enc = employer_enc - profile.is_cumulative = is_cumulative - profile.updated_at = now - action = "tax_profile_update" - - await db.flush() - await write_audit(db, user_id=user_id, action=action, - resource_type="tax_profile", resource_id=profile.id) - return profile - - -def _profile_to_response(profile) -> dict: - from app.db.models.tax import TaxProfile - employer = None - if profile.employer_name_enc: - try: - employer = decrypt_field(profile.employer_name_enc) - except Exception: - employer = None - return { - "id": str(profile.id), - "tax_year": profile.tax_year, - "tax_code": profile.tax_code, - "employer_name": employer, - "is_cumulative": profile.is_cumulative, - "created_at": profile.created_at.isoformat(), - "updated_at": profile.updated_at.isoformat(), - } - - -# --------------------------------------------------------------------------- -# Payslip CRUD -# --------------------------------------------------------------------------- - -async def list_payslips(db: AsyncSession, user_id: uuid.UUID, tax_year: int) -> list: - from app.db.models.tax import Payslip, TaxProfile - result = await db.execute( - select(Payslip) - .join(TaxProfile, Payslip.tax_profile_id == TaxProfile.id) - .where( - Payslip.user_id == user_id, - TaxProfile.tax_year == tax_year, - ) - .order_by(Payslip.period_year, Payslip.period_month.nulls_last()) - ) - return list(result.scalars()) - - -async def create_payslip( - db: AsyncSession, - user_id: uuid.UUID, - tax_year: int, - period_month: int | None, - period_year: int, - gross_pay: Decimal, - income_tax_withheld: Decimal, - ni_withheld: Decimal, - net_pay: Decimal, - is_p60: bool = False, - notes: str | None = None, -): - from app.db.models.tax import Payslip - from app.core.audit import write_audit - - profile = await get_tax_profile(db, user_id, tax_year) - if profile is None: - raise ValueError(f"No tax profile for year {tax_year}. Create a profile first.") - - now = datetime.now(timezone.utc) - notes_enc = encrypt_field(notes) if notes else None - - payslip = Payslip( - id=uuid.uuid4(), - user_id=user_id, - tax_profile_id=profile.id, - period_month=period_month, - period_year=period_year, - gross_pay=gross_pay, - income_tax_withheld=income_tax_withheld, - ni_withheld=ni_withheld, - net_pay=net_pay, - is_p60=is_p60, - notes_enc=notes_enc, - created_at=now, - ) - db.add(payslip) - await db.flush() - await write_audit(db, user_id=user_id, action="payslip_create", - resource_type="payslip", resource_id=payslip.id) - return payslip - - -async def update_payslip( - db: AsyncSession, - user_id: uuid.UUID, - payslip_id: uuid.UUID, - **kwargs, -): - from app.db.models.tax import Payslip - from app.core.audit import write_audit - - result = await db.execute( - select(Payslip).where(Payslip.id == payslip_id, Payslip.user_id == user_id) - ) - payslip = result.scalar_one_or_none() - if payslip is None: - raise ValueError("Payslip not found") - - for field, value in kwargs.items(): - if field == "notes": - payslip.notes_enc = encrypt_field(value) if value else None - else: - setattr(payslip, field, value) - - await db.flush() - await write_audit(db, user_id=user_id, action="payslip_update", - resource_type="payslip", resource_id=payslip.id) - return payslip - - -async def delete_payslip(db: AsyncSession, user_id: uuid.UUID, payslip_id: uuid.UUID) -> None: - from app.db.models.tax import Payslip - from app.core.audit import write_audit - - result = await db.execute( - select(Payslip).where(Payslip.id == payslip_id, Payslip.user_id == user_id) - ) - payslip = result.scalar_one_or_none() - if payslip is None: - raise ValueError("Payslip not found") - - await write_audit(db, user_id=user_id, action="payslip_delete", - resource_type="payslip", resource_id=payslip_id) - await db.delete(payslip) - await db.flush() - - -async def replace_with_p60( - db: AsyncSession, - user_id: uuid.UUID, - tax_year: int, - gross_pay: Decimal, - income_tax_withheld: Decimal, - ni_withheld: Decimal, - net_pay: Decimal, -) -> None: - """Delete all existing payslips for the tax year and replace with a single P60.""" - from app.db.models.tax import Payslip, TaxProfile - from app.core.audit import write_audit - - profile = await get_tax_profile(db, user_id, tax_year) - if profile is None: - raise ValueError(f"No tax profile for year {tax_year}. Create a profile first.") - - await db.execute( - delete(Payslip).where( - Payslip.user_id == user_id, - Payslip.tax_profile_id == profile.id, - ) - ) - - now = datetime.now(timezone.utc) - p60 = Payslip( - id=uuid.uuid4(), - user_id=user_id, - tax_profile_id=profile.id, - period_month=None, - period_year=tax_year - 1, # P60 covers year ending 5 Apr tax_year; the employer year starts the prior calendar year - gross_pay=gross_pay, - income_tax_withheld=income_tax_withheld, - ni_withheld=ni_withheld, - net_pay=net_pay, - is_p60=True, - notes_enc=None, - created_at=now, - ) - db.add(p60) - await db.flush() - await write_audit(db, user_id=user_id, action="payslip_p60_replace", - resource_type="payslip", resource_id=p60.id) - - -def _payslip_to_response(payslip) -> dict: - notes = None - if payslip.notes_enc: - try: - notes = decrypt_field(payslip.notes_enc) - except Exception: - notes = None - return { - "id": str(payslip.id), - "tax_profile_id": str(payslip.tax_profile_id), - "period_month": payslip.period_month, - "period_year": payslip.period_year, - "gross_pay": str(payslip.gross_pay), - "income_tax_withheld": str(payslip.income_tax_withheld), - "ni_withheld": str(payslip.ni_withheld), - "net_pay": str(payslip.net_pay), - "is_p60": payslip.is_p60, - "notes": notes, - "created_at": payslip.created_at.isoformat(), - } - - -# --------------------------------------------------------------------------- -# Manual CGT disposal CRUD -# --------------------------------------------------------------------------- - -async def list_manual_disposals( - db: AsyncSession, user_id: uuid.UUID, tax_year: int -) -> list: - from app.db.models.tax import ManualCGTDisposal - result = await db.execute( - select(ManualCGTDisposal).where( - ManualCGTDisposal.user_id == user_id, - ManualCGTDisposal.tax_year == tax_year, - ).order_by(ManualCGTDisposal.disposal_date) - ) - return list(result.scalars()) - - -async def create_manual_disposal( - db: AsyncSession, - user_id: uuid.UUID, - tax_year: int, - disposal_date: date, - asset_description: str, - proceeds: Decimal, - cost_basis: Decimal, - notes: str | None = None, -): - from app.db.models.tax import ManualCGTDisposal - from app.core.audit import write_audit - - now = datetime.now(timezone.utc) - disposal = ManualCGTDisposal( - id=uuid.uuid4(), - user_id=user_id, - tax_year=tax_year, - disposal_date=disposal_date, - asset_description_enc=encrypt_field(asset_description), - proceeds=proceeds, - cost_basis=cost_basis, - notes_enc=encrypt_field(notes) if notes else None, - created_at=now, - ) - db.add(disposal) - await db.flush() - await write_audit(db, user_id=user_id, action="cgt_disposal_create", - resource_type="manual_cgt_disposal", resource_id=disposal.id) - return disposal - - -async def update_manual_disposal( - db: AsyncSession, - user_id: uuid.UUID, - disposal_id: uuid.UUID, - disposal_date: date, - asset_description: str, - proceeds: Decimal, - cost_basis: Decimal, - notes: str | None = None, -): - from app.db.models.tax import ManualCGTDisposal - from app.core.audit import write_audit - - result = await db.execute( - select(ManualCGTDisposal).where( - ManualCGTDisposal.id == disposal_id, - ManualCGTDisposal.user_id == user_id, - ) - ) - disposal = result.scalar_one_or_none() - if disposal is None: - raise ValueError("Disposal not found") - - disposal.disposal_date = disposal_date - disposal.asset_description_enc = encrypt_field(asset_description) - disposal.proceeds = proceeds - disposal.cost_basis = cost_basis - disposal.notes_enc = encrypt_field(notes) if notes else None - await db.flush() - await write_audit(db, user_id=user_id, action="cgt_disposal_update", - resource_type="manual_cgt_disposal", resource_id=disposal.id) - return disposal - - -async def delete_manual_disposal( - db: AsyncSession, user_id: uuid.UUID, disposal_id: uuid.UUID -) -> None: - from app.db.models.tax import ManualCGTDisposal - from app.core.audit import write_audit - - result = await db.execute( - select(ManualCGTDisposal).where( - ManualCGTDisposal.id == disposal_id, - ManualCGTDisposal.user_id == user_id, - ) - ) - disposal = result.scalar_one_or_none() - if disposal is None: - raise ValueError("Disposal not found") - - await write_audit(db, user_id=user_id, action="cgt_disposal_delete", - resource_type="manual_cgt_disposal", resource_id=disposal_id) - await db.delete(disposal) - await db.flush() - - -def _disposal_to_response(disposal) -> dict: - asset_desc = "" - try: - asset_desc = decrypt_field(disposal.asset_description_enc) - except Exception: - pass - notes = None - if disposal.notes_enc: - try: - notes = decrypt_field(disposal.notes_enc) - except Exception: - pass - gain_loss = disposal.proceeds - disposal.cost_basis - return { - "id": str(disposal.id), - "tax_year": disposal.tax_year, - "disposal_date": disposal.disposal_date.isoformat(), - "asset_description": asset_desc, - "proceeds": str(disposal.proceeds), - "cost_basis": str(disposal.cost_basis), - "gain_loss": str(gain_loss), - "notes": notes, - "created_at": disposal.created_at.isoformat(), - } - - -# --------------------------------------------------------------------------- -# Tax rate config CRUD -# --------------------------------------------------------------------------- - -async def list_configured_years(db: AsyncSession, user_id: uuid.UUID) -> list[int]: - from app.db.models.tax import TaxRateConfig - result = await db.execute( - select(TaxRateConfig.tax_year) - .where(TaxRateConfig.user_id == user_id) - .distinct() - .order_by(TaxRateConfig.tax_year) - ) - return [row[0] for row in result] - - -async def get_rate_config( - db: AsyncSession, user_id: uuid.UUID, tax_year: int -) -> dict: - from app.db.models.tax import TaxRateConfig - result = await db.execute( - select(TaxRateConfig).where( - TaxRateConfig.user_id == user_id, - TaxRateConfig.tax_year == tax_year, - ) - ) - rows = list(result.scalars()) - if not rows: - raise ValueError(f"No rate config for year {tax_year}") - return { - "tax_year": tax_year, - "rates": {row.rate_type: row.config for row in rows}, - "updated_at": max(row.updated_at for row in rows).isoformat(), - } - - -async def upsert_rate_config( - db: AsyncSession, - user_id: uuid.UUID, - tax_year: int, - rates: dict, -) -> dict: - from app.db.models.tax import TaxRateConfig - from app.core.audit import write_audit - - now = datetime.now(timezone.utc) - for rate_type, config in rates.items(): - result = await db.execute( - select(TaxRateConfig).where( - TaxRateConfig.user_id == user_id, - TaxRateConfig.tax_year == tax_year, - TaxRateConfig.rate_type == rate_type, - ) - ) - row = result.scalar_one_or_none() - if row is None: - db.add(TaxRateConfig( - id=uuid.uuid4(), - user_id=user_id, - tax_year=tax_year, - rate_type=rate_type, - config=config, - updated_at=now, - )) - else: - row.config = config - row.updated_at = now - - await db.flush() - await write_audit(db, user_id=user_id, action="tax_rate_config_update", - resource_type="tax_rate_config", - resource_id=None, - metadata={"tax_year": tax_year}) - return await get_rate_config(db, user_id, tax_year) - - -# --------------------------------------------------------------------------- -# Report builder -# --------------------------------------------------------------------------- - -async def build_tax_report( - db: AsyncSession, user_id: uuid.UUID, tax_year: int -) -> dict[str, Any]: - """Build the full tax report for a given year. - - Steps: - 1. Load rates - 2. Load profile + payslip totals - 3. Load investment sell disposals within the tax year - 4. Load investment dividend transactions within the tax year - 5. Load manual CGT disposals - 6. Run calculations: income tax → NI → CGT → dividend tax - 7. Return full report dict - """ - from app.db.models.tax import ManualCGTDisposal, Payslip, TaxProfile - from app.db.models.investment_transaction import InvestmentTransaction - from app.db.models.investment_holding import InvestmentHolding - from app.db.models.asset import Asset - - rates = await load_rates(db, user_id, tax_year) - start_date, end_date = tax_year_date_range(tax_year) - - # ---- Profile ---- - profile = await get_tax_profile(db, user_id, tax_year) - tax_code = profile.tax_code if profile else "1257L" - profile_data = _profile_to_response(profile) if profile else None - - # ---- Payslip totals ---- - payslips = await list_payslips(db, user_id, tax_year) - gross_income = sum((Decimal(str(p.gross_pay)) for p in payslips), Decimal("0")) - income_tax_withheld = sum((Decimal(str(p.income_tax_withheld)) for p in payslips), Decimal("0")) - ni_withheld = sum((Decimal(str(p.ni_withheld)) for p in payslips), Decimal("0")) - payslip_rows = [_payslip_to_response(p) for p in payslips] - - # ---- Investment sell disposals ---- - inv_disposals_result = await db.execute( - select(InvestmentTransaction, InvestmentHolding, Asset) - .join(InvestmentHolding, InvestmentTransaction.holding_id == InvestmentHolding.id) - .join(Asset, InvestmentHolding.asset_id == Asset.id) - .where( - InvestmentTransaction.user_id == user_id, - InvestmentTransaction.type == "sell", - InvestmentTransaction.date >= start_date, - InvestmentTransaction.date <= end_date, - ) - .order_by(InvestmentTransaction.date) - ) - inv_disposal_rows = [] - total_inv_gain = Decimal("0") - for inv_txn, holding, asset in inv_disposals_result: - proceeds = Decimal(str(inv_txn.total_amount)) - cost = Decimal(str(inv_txn.quantity)) * Decimal(str(holding.avg_cost_basis)) - gain = proceeds - cost - Decimal(str(inv_txn.fees)) - total_inv_gain += gain - inv_disposal_rows.append({ - "date": inv_txn.date.isoformat(), - "asset": asset.name, - "symbol": asset.symbol, - "quantity": str(inv_txn.quantity), - "proceeds": str(proceeds), - "cost_basis": str(cost), - "fees": str(inv_txn.fees), - "gain_loss": str(gain), - }) - - # ---- Manual CGT disposals ---- - manual_disposals = await list_manual_disposals(db, user_id, tax_year) - manual_disposal_rows = [_disposal_to_response(d) for d in manual_disposals] - total_manual_gain = sum( - (Decimal(str(d.proceeds)) - Decimal(str(d.cost_basis)) for d in manual_disposals), - Decimal("0"), - ) - total_cgt_gain = total_inv_gain + total_manual_gain - - # ---- Investment dividends ---- - div_result = await db.execute( - select(InvestmentTransaction, InvestmentHolding, Asset) - .join(InvestmentHolding, InvestmentTransaction.holding_id == InvestmentHolding.id) - .join(Asset, InvestmentHolding.asset_id == Asset.id) - .where( - InvestmentTransaction.user_id == user_id, - InvestmentTransaction.type == "dividend", - InvestmentTransaction.date >= start_date, - InvestmentTransaction.date <= end_date, - ) - .order_by(InvestmentTransaction.date) - ) - dividend_rows = [] - total_dividends = Decimal("0") - for inv_txn, holding, asset in div_result: - amount = Decimal(str(inv_txn.total_amount)) - total_dividends += amount - dividend_rows.append({ - "date": inv_txn.date.isoformat(), - "asset": asset.name, - "symbol": asset.symbol, - "amount": str(amount), - }) - - # ---- Calculations ---- - income_tax_result = calculate_income_tax(gross_income, tax_code, rates) - ni_result = calculate_ni(gross_income, rates) - cgt_result = calculate_cgt( - total_cgt_gain, - income_tax_result["remaining_basic_rate_band"], - rates, - ) - dividend_result = calculate_dividend_tax( - total_dividends, - income_tax_result["remaining_basic_rate_band"], - rates, - ) - - # ---- Totals ---- - total_liability = ( - income_tax_result["liability"] - + ni_result["liability"] - + cgt_result["liability"] - + dividend_result["liability"] - ) - total_withheld = income_tax_withheld + ni_withheld - net_owed = total_liability - total_withheld - - return { - "tax_year": tax_year, - "tax_year_display": f"{tax_year - 1}/{str(tax_year)[2:]}", - "profile": profile_data, - "income": { - "gross_income": str(gross_income), - "income_tax_withheld": str(income_tax_withheld), - "ni_withheld": str(ni_withheld), - "payslips": payslip_rows, - }, - "income_tax": { - **{k: str(v) if isinstance(v, Decimal) else v - for k, v in income_tax_result.items() - if k != "remaining_basic_rate_band"}, - "withheld": str(income_tax_withheld), - "owed": str(income_tax_result["liability"] - income_tax_withheld), - }, - "ni": { - **{k: str(v) if isinstance(v, Decimal) else v - for k, v in ni_result.items()}, - "withheld": str(ni_withheld), - "owed": str(ni_result["liability"] - ni_withheld), - }, - "cgt": { - **{k: str(v) if isinstance(v, Decimal) else v - for k, v in cgt_result.items()}, - "investment_disposals": inv_disposal_rows, - "manual_disposals": manual_disposal_rows, - "total_gain": str(total_cgt_gain), - }, - "dividends": { - **{k: str(v) if isinstance(v, Decimal) else v - for k, v in dividend_result.items()}, - "dividend_transactions": dividend_rows, - }, - "summary": { - "total_liability": str(total_liability), - "total_withheld": str(total_withheld), - "net_owed": str(net_owed), - "overpaid": net_owed < 0, - }, - } diff --git a/backend/app/services/transaction_service.py b/backend/app/services/transaction_service.py index 97e42fa..ad2af19 100644 --- a/backend/app/services/transaction_service.py +++ b/backend/app/services/transaction_service.py @@ -47,7 +47,6 @@ def _to_response(t: Transaction) -> dict: "notes": _dec(t.notes_enc), "tags": t.tags or [], "is_recurring": t.is_recurring, - "recurring_rule": t.recurring_rule, "attachment_refs": t.attachment_refs or [], "created_at": t.created_at, "updated_at": t.updated_at, @@ -222,10 +221,6 @@ async def update_transaction( txn.notes_enc = _enc(data.notes) if data.tags is not None: txn.tags = data.tags - if data.is_recurring is not None: - txn.is_recurring = data.is_recurring - if data.recurring_rule is not None: - txn.recurring_rule = data.recurring_rule txn.updated_at = now await db.flush() @@ -312,7 +307,5 @@ async def import_csv( await db.flush() if imported > 0: await recalculate_balance(db, account_id) - from app.services.recurring_service import detect_recurring - await detect_recurring(db, user_id) return {"imported": imported, "skipped": skipped} diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py deleted file mode 100644 index 68e8f9c..0000000 --- a/backend/tests/conftest.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Conftest for backend tests. -Pure calculation tests (test_tax_calculations.py) import from tax_calculations.py -which has no external dependencies, so no stubs are needed. -""" -import sys -import os - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) diff --git a/backend/tests/test_tax_calculations.py b/backend/tests/test_tax_calculations.py deleted file mode 100644 index 20e4179..0000000 --- a/backend/tests/test_tax_calculations.py +++ /dev/null @@ -1,311 +0,0 @@ -""" -Unit tests for the pure tax calculation functions. - -Reference figures verified against HMRC's tax calculator and HMRC guidance. -All tests use the 2025/26 frozen rate structure (same as seed data). -""" -import sys -import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - -from decimal import Decimal -from datetime import date -import pytest - -from app.services.tax_calculations import ( - tax_year_for_date, - parse_tax_code, - calculate_income_tax, - calculate_ni, - calculate_cgt, - calculate_dividend_tax, -) - - -# --------------------------------------------------------------------------- -# Shared rate fixture (mirrors the seeded data) -# --------------------------------------------------------------------------- - -RATES = { - "income_tax": { - "bands": [ - {"from": 0, "to": 12570, "rate": 0.00}, - {"from": 12570, "to": 50270, "rate": 0.20}, - {"from": 50270, "to": 125140, "rate": 0.40}, - {"from": 125140, "to": None, "rate": 0.45}, - ] - }, - "ni": { - "bands": [ - {"from": 0, "to": 12570, "rate": 0.00}, - {"from": 12570, "to": 50270, "rate": 0.08}, - {"from": 50270, "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, - }, -} - - -# --------------------------------------------------------------------------- -# tax_year_for_date -# --------------------------------------------------------------------------- - -class TestTaxYearForDate: - def test_before_april_6(self): - assert tax_year_for_date(date(2025, 4, 5)) == 2025 - - def test_on_april_6(self): - assert tax_year_for_date(date(2025, 4, 6)) == 2026 - - def test_mid_year(self): - assert tax_year_for_date(date(2025, 10, 1)) == 2026 - - def test_january(self): - assert tax_year_for_date(date(2026, 1, 15)) == 2026 - - def test_april_5_boundary(self): - # 5 April 2024 → tax_year 2024 - assert tax_year_for_date(date(2024, 4, 5)) == 2024 - - def test_april_6_boundary(self): - # 6 April 2024 → tax_year 2025 - assert tax_year_for_date(date(2024, 4, 6)) == 2025 - - -# --------------------------------------------------------------------------- -# parse_tax_code -# --------------------------------------------------------------------------- - -class TestParseTaxCode: - def test_standard_1257l(self): - r = parse_tax_code("1257L") - assert r["allowance"] == Decimal("12570") - assert r["rate_override"] is None - assert r["k_code"] is False - assert r["no_tax"] is False - - def test_standard_1257m(self): - assert parse_tax_code("1257M")["allowance"] == Decimal("12570") - - def test_standard_1257n(self): - assert parse_tax_code("1257N")["allowance"] == Decimal("12570") - - def test_br(self): - r = parse_tax_code("BR") - assert r["allowance"] == Decimal("0") - assert r["rate_override"] == Decimal("0.20") - - def test_d0(self): - r = parse_tax_code("D0") - assert r["rate_override"] == Decimal("0.40") - - def test_d1(self): - r = parse_tax_code("D1") - assert r["rate_override"] == Decimal("0.45") - - def test_nt(self): - r = parse_tax_code("NT") - assert r["no_tax"] is True - - def test_0t(self): - r = parse_tax_code("0T") - assert r["allowance"] == Decimal("0") - assert r["rate_override"] is None - - def test_k_code(self): - r = parse_tax_code("K100") - assert r["allowance"] == Decimal("-1000") - assert r["k_code"] is True - - def test_k_code_large(self): - r = parse_tax_code("K497") - assert r["allowance"] == Decimal("-4970") - - def test_w1_suffix_stripped(self): - r = parse_tax_code("1257L W1") - assert r["allowance"] == Decimal("12570") - - def test_m1_suffix_stripped(self): - r = parse_tax_code("1257L/M1") - assert r["allowance"] == Decimal("12570") - - def test_lowercase(self): - r = parse_tax_code("1257l") - assert r["allowance"] == Decimal("12570") - - -# --------------------------------------------------------------------------- -# calculate_income_tax -# --------------------------------------------------------------------------- - -class TestCalculateIncomeTax: - def test_below_personal_allowance(self): - r = calculate_income_tax(Decimal("10000"), "1257L", RATES) - assert r["liability"] == Decimal("0") - assert r["taxable_income"] == Decimal("0") - assert r["personal_allowance"] == Decimal("12570") - - def test_at_personal_allowance_boundary(self): - r = calculate_income_tax(Decimal("12570"), "1257L", RATES) - assert r["liability"] == Decimal("0") - - def test_basic_rate_salary_30k(self): - # £30,000 gross — taxable = £17,430, basic rate 20% - r = calculate_income_tax(Decimal("30000"), "1257L", RATES) - assert r["taxable_income"] == Decimal("17430") - assert r["liability"] == Decimal("3486.00") - - def test_at_higher_rate_threshold(self): - # £50,270 gross — exactly at the basic rate upper; taxable = £37,700 - r = calculate_income_tax(Decimal("50270"), "1257L", RATES) - assert r["taxable_income"] == Decimal("37700") - # 20% on (50270 - 12570) = 37700 - expected = (Decimal("37700") * Decimal("0.20")).quantize(Decimal("0.01")) - assert r["liability"] == expected - - def test_higher_rate_taxpayer_60k(self): - # £60,000 gross, 1257L - # 20% on (50270 - 12570) = 37700 → £7,540 - # 40% on (60000 - 50270) = 9730 → £3,892 - r = calculate_income_tax(Decimal("60000"), "1257L", RATES) - assert r["taxable_income"] == Decimal("47430") # 60000 - 12570 - expected_basic = (Decimal("37700") * Decimal("0.20")).quantize(Decimal("0.01")) - expected_higher = (Decimal("9730") * Decimal("0.40")).quantize(Decimal("0.01")) - assert r["liability"] == expected_basic + expected_higher - - def test_personal_allowance_taper_110k(self): - # £110,000 — allowance tapered by £5,000 → £7,570 - r = calculate_income_tax(Decimal("110000"), "1257L", RATES) - assert r["personal_allowance"] == Decimal("7570") - - def test_personal_allowance_taper_125140(self): - # At £125,140 allowance tapers to zero - r = calculate_income_tax(Decimal("125140"), "1257L", RATES) - assert r["personal_allowance"] == Decimal("0") - - def test_personal_allowance_above_125140(self): - # Above £125,140 allowance stays at zero - r = calculate_income_tax(Decimal("150000"), "1257L", RATES) - assert r["personal_allowance"] == Decimal("0") - - def test_br_code(self): - r = calculate_income_tax(Decimal("30000"), "BR", RATES) - assert r["liability"] == (Decimal("30000") * Decimal("0.20")).quantize(Decimal("0.01")) - - def test_d0_code(self): - r = calculate_income_tax(Decimal("30000"), "D0", RATES) - assert r["liability"] == (Decimal("30000") * Decimal("0.40")).quantize(Decimal("0.01")) - - def test_nt_code(self): - r = calculate_income_tax(Decimal("100000"), "NT", RATES) - assert r["liability"] == Decimal("0") - - def test_k_code_increases_taxable(self): - # K100 = -£1000 allowance → taxable = gross + £1000 - r = calculate_income_tax(Decimal("30000"), "K100", RATES) - assert r["taxable_income"] == Decimal("31000") - - def test_remaining_basic_rate_band_basic_taxpayer(self): - # £30k gross → remaining = 50270 - 30000 = 20270 - r = calculate_income_tax(Decimal("30000"), "1257L", RATES) - assert r["remaining_basic_rate_band"] == Decimal("20270") - - def test_remaining_basic_rate_band_higher_taxpayer(self): - # £60k gross > £50270 → band exhausted - r = calculate_income_tax(Decimal("60000"), "1257L", RATES) - assert r["remaining_basic_rate_band"] == Decimal("0") - - -# --------------------------------------------------------------------------- -# calculate_ni -# --------------------------------------------------------------------------- - -class TestCalculateNI: - def test_below_threshold(self): - r = calculate_ni(Decimal("12570"), RATES) - assert r["liability"] == Decimal("0") - - def test_basic_rate_salary_30k(self): - # NI on (30000 - 12570) = £17,430 at 8% = £1,394.40 - r = calculate_ni(Decimal("30000"), RATES) - assert r["liability"] == Decimal("1394.40") - - def test_above_upper_earnings_limit(self): - # NI on (50270 - 12570) = £37,700 at 8% = £3,016 + (60000 - 50270) = £9,730 at 2% = £194.60 - r = calculate_ni(Decimal("60000"), RATES) - assert r["liability"] == Decimal("3016.00") + Decimal("194.60") - - -# --------------------------------------------------------------------------- -# calculate_cgt -# --------------------------------------------------------------------------- - -class TestCalculateCGT: - def test_below_exempt(self): - r = calculate_cgt(Decimal("1000"), Decimal("20000"), RATES) - assert r["liability"] == Decimal("0") - assert r["taxable_gain"] == Decimal("0") - - def test_exactly_exempt(self): - r = calculate_cgt(Decimal("3000"), Decimal("20000"), RATES) - assert r["liability"] == Decimal("0") - - def test_basic_rate_taxpayer(self): - # Gain £10,000 — exempt £3,000 — taxable £7,000 all at basic rate 18% - r = calculate_cgt(Decimal("10000"), Decimal("20000"), RATES) - assert r["taxable_gain"] == Decimal("7000") - assert r["liability"] == (Decimal("7000") * Decimal("0.18")).quantize(Decimal("0.01")) - - def test_higher_rate_taxpayer(self): - # remaining_basic_rate_band = 0 → all at higher rate 24% - r = calculate_cgt(Decimal("10000"), Decimal("0"), RATES) - assert r["liability"] == (Decimal("7000") * Decimal("0.24")).quantize(Decimal("0.01")) - - def test_split_basic_higher(self): - # taxable gain £7,000; remaining_brb £4,000 → £4k at 18%, £3k at 24% - r = calculate_cgt(Decimal("10000"), Decimal("4000"), RATES) - expected = ( - (Decimal("4000") * Decimal("0.18")) + - (Decimal("3000") * Decimal("0.24")) - ).quantize(Decimal("0.01")) - assert r["liability"] == expected - - def test_negative_gain_no_tax(self): - r = calculate_cgt(Decimal("-5000"), Decimal("20000"), RATES) - assert r["liability"] == Decimal("0") - assert r["taxable_gain"] == Decimal("0") - - -# --------------------------------------------------------------------------- -# calculate_dividend_tax -# --------------------------------------------------------------------------- - -class TestCalculateDividendTax: - def test_within_allowance(self): - r = calculate_dividend_tax(Decimal("400"), Decimal("20000"), RATES) - assert r["liability"] == Decimal("0") - - def test_exactly_allowance(self): - r = calculate_dividend_tax(Decimal("500"), Decimal("20000"), RATES) - assert r["liability"] == Decimal("0") - - def test_basic_rate_band(self): - # £1,500 dividends — allowance £500 — taxable £1,000 at basic 8.75% - r = calculate_dividend_tax(Decimal("1500"), Decimal("20000"), RATES) - assert r["taxable_dividends"] == Decimal("1000") - assert r["liability"] == (Decimal("1000") * Decimal("0.0875")).quantize(Decimal("0.01")) - - def test_higher_rate_band(self): - # Remaining basic = 0 → taxable £1,000 at higher 33.75% - r = calculate_dividend_tax(Decimal("1500"), Decimal("0"), RATES) - assert r["liability"] == (Decimal("1000") * Decimal("0.3375")).quantize(Decimal("0.01")) - - def test_no_dividends(self): - r = calculate_dividend_tax(Decimal("0"), Decimal("20000"), RATES) - assert r["liability"] == Decimal("0") diff --git a/backend/tests/test_tax_schemas.py b/backend/tests/test_tax_schemas.py deleted file mode 100644 index f085d8c..0000000 --- a/backend/tests/test_tax_schemas.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -Schema round-trip tests for tax.py Pydantic models. - -Verifies that each schema accepts valid data, rejects invalid data, -and that the nested TaxReportResponse correctly validates the shape -returned by build_tax_report(). -""" -import sys, os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - -import uuid -from datetime import date -from decimal import Decimal - -import pytest -from pydantic import ValidationError - -from app.schemas.tax import ( - ManualDisposalCreate, - ManualDisposalUpdate, - P60Entry, - PayslipCreate, - PayslipUpdate, - TaxProfileCreate, - TaxRateConfigUpdate, - TaxReportResponse, -) - - -# --------------------------------------------------------------------------- -# TaxRateConfigUpdate -# --------------------------------------------------------------------------- - -class TestTaxRateConfigUpdate: - def test_partial_update_accepted(self): - u = TaxRateConfigUpdate(cgt={"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24}) - assert u.cgt["exempt"] == 3000 - assert u.income_tax is None - - def test_all_none_valid(self): - u = TaxRateConfigUpdate() - assert u.income_tax is None - assert u.ni is None - - -# --------------------------------------------------------------------------- -# TaxProfileCreate -# --------------------------------------------------------------------------- - -class TestTaxProfileCreate: - def test_defaults(self): - p = TaxProfileCreate() - assert p.tax_code == "1257L" - assert p.is_cumulative is True - assert p.employer_name is None - - def test_custom_values(self): - p = TaxProfileCreate(tax_code="BR", employer_name="Acme Ltd", is_cumulative=False) - assert p.tax_code == "BR" - assert p.employer_name == "Acme Ltd" - - def test_tax_code_too_long(self): - with pytest.raises(ValidationError): - TaxProfileCreate(tax_code="X" * 21) - - -# --------------------------------------------------------------------------- -# PayslipCreate -# --------------------------------------------------------------------------- - -class TestPayslipCreate: - def test_valid(self): - p = PayslipCreate( - period_month=4, - period_year=2024, - gross_pay=Decimal("3000.00"), - income_tax_withheld=Decimal("286.00"), - ni_withheld=Decimal("220.00"), - net_pay=Decimal("2494.00"), - ) - assert p.period_month == 4 - assert p.gross_pay == Decimal("3000.00") - - def test_invalid_month(self): - with pytest.raises(ValidationError): - PayslipCreate( - period_month=13, - period_year=2024, - gross_pay=Decimal("3000"), - income_tax_withheld=Decimal("286"), - ni_withheld=Decimal("220"), - net_pay=Decimal("2494"), - ) - - def test_negative_gross_rejected(self): - with pytest.raises(ValidationError): - PayslipCreate( - period_month=4, - period_year=2024, - gross_pay=Decimal("-1"), - income_tax_withheld=Decimal("0"), - ni_withheld=Decimal("0"), - net_pay=Decimal("0"), - ) - - def test_p60_no_month(self): - p = PayslipCreate( - period_month=None, - period_year=2024, - gross_pay=Decimal("36000"), - income_tax_withheld=Decimal("4686"), - ni_withheld=Decimal("2394"), - net_pay=Decimal("28920"), - ) - assert p.period_month is None - - -# --------------------------------------------------------------------------- -# P60Entry -# --------------------------------------------------------------------------- - -class TestP60Entry: - def test_valid(self): - e = P60Entry( - gross_pay=Decimal("36000"), - income_tax_withheld=Decimal("4686"), - ni_withheld=Decimal("2394"), - net_pay=Decimal("28920"), - ) - assert e.gross_pay == Decimal("36000") - - def test_negative_rejected(self): - with pytest.raises(ValidationError): - P60Entry( - gross_pay=Decimal("-1"), - income_tax_withheld=Decimal("0"), - ni_withheld=Decimal("0"), - net_pay=Decimal("0"), - ) - - -# --------------------------------------------------------------------------- -# ManualDisposalCreate -# --------------------------------------------------------------------------- - -class TestManualDisposalCreate: - def test_valid(self): - d = ManualDisposalCreate( - disposal_date=date(2025, 1, 15), - asset_description="Rental property", - proceeds=Decimal("250000"), - cost_basis=Decimal("200000"), - ) - assert d.proceeds == Decimal("250000") - assert d.notes is None - - def test_empty_description_rejected(self): - with pytest.raises(ValidationError): - ManualDisposalCreate( - disposal_date=date(2025, 1, 15), - asset_description="", - proceeds=Decimal("1000"), - cost_basis=Decimal("500"), - ) - - def test_negative_proceeds_rejected(self): - with pytest.raises(ValidationError): - ManualDisposalCreate( - disposal_date=date(2025, 1, 15), - asset_description="Something", - proceeds=Decimal("-1"), - cost_basis=Decimal("500"), - ) - - -# --------------------------------------------------------------------------- -# TaxReportResponse — validates the full nested report shape -# --------------------------------------------------------------------------- - -SAMPLE_REPORT = { - "tax_year": 2025, - "tax_year_display": "2024/25", - "profile": { - "id": str(uuid.uuid4()), - "tax_year": 2025, - "tax_code": "1257L", - "employer_name": "Acme Ltd", - "is_cumulative": True, - "created_at": "2025-01-01T00:00:00+00:00", - "updated_at": "2025-01-01T00:00:00+00:00", - }, - "income": { - "gross_income": "45000.00", - "income_tax_withheld": "6486.00", - "ni_withheld": "2634.00", - "payslips": [], - }, - "income_tax": { - "personal_allowance": "12570.00", - "taxable_income": "32430.00", - "liability": "6486.00", - "band_breakdown": [{"rate": 0.20, "taxable": 32430.0, "tax": 6486.0}], - "withheld": "6486.00", - "owed": "0.00", - }, - "ni": { - "liability": "2634.00", - "band_breakdown": [{"rate": 0.08, "taxable": 32430.0, "tax": 2594.4}], - "withheld": "2634.00", - "owed": "0.00", - }, - "cgt": { - "gross_gain": "0.00", - "exempt": "0.00", - "taxable_gain": "0.00", - "liability": "0.00", - "band_breakdown": [], - "investment_disposals": [], - "manual_disposals": [], - "total_gain": "0.00", - }, - "dividends": { - "gross_dividends": "0.00", - "allowance": "0.00", - "taxable_dividends": "0.00", - "liability": "0.00", - "band_breakdown": [], - "dividend_transactions": [], - }, - "summary": { - "total_liability": "9120.00", - "total_withheld": "9120.00", - "net_owed": "0.00", - "overpaid": False, - }, -} - - -class TestTaxReportResponse: - def test_valid_report_parses(self): - r = TaxReportResponse(**SAMPLE_REPORT) - assert r.tax_year == 2025 - assert r.tax_year_display == "2024/25" - assert r.summary.net_owed == "0.00" - assert r.summary.overpaid is False - - def test_no_profile(self): - report = {**SAMPLE_REPORT, "profile": None} - r = TaxReportResponse(**report) - assert r.profile is None - - def test_missing_summary_field_rejected(self): - bad_summary = {**SAMPLE_REPORT["summary"]} - del bad_summary["overpaid"] - with pytest.raises(ValidationError): - TaxReportResponse(**{**SAMPLE_REPORT, "summary": bad_summary}) - - def test_missing_income_field_rejected(self): - bad_income = {**SAMPLE_REPORT["income"]} - del bad_income["gross_income"] - with pytest.raises(ValidationError): - TaxReportResponse(**{**SAMPLE_REPORT, "income": bad_income}) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js deleted file mode 100644 index 3064b64..0000000 --- a/frontend/eslint.config.js +++ /dev/null @@ -1,30 +0,0 @@ -import tseslint from "@typescript-eslint/eslint-plugin"; -import tsParser from "@typescript-eslint/parser"; -import reactHooks from "eslint-plugin-react-hooks"; - -export default [ - { - ignores: ["dist/**", "node_modules/**"], - }, - { - files: ["src/**/*.{ts,tsx}"], - languageOptions: { - parser: tsParser, - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - ecmaFeatures: { jsx: true }, - }, - }, - plugins: { - "@typescript-eslint": tseslint, - "react-hooks": reactHooks, - }, - rules: { - ...tseslint.configs.recommended.rules, - ...reactHooks.configs.recommended.rules, - "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], - "@typescript-eslint/no-explicit-any": "warn", - }, - }, -]; diff --git a/frontend/package.json b/frontend/package.json index fb06bde..31228d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "eslint src" + "lint": "eslint src --ext ts,tsx" }, "dependencies": { "react": "^18.3.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ff9e8f..f172bea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,8 +16,6 @@ import PortfolioPage from "@/pages/investments/PortfolioPage"; import AssetDetail from "@/pages/investments/AssetDetail"; import PredictionsPage from "@/pages/predictions/PredictionsPage"; import SettingsPage from "@/pages/settings/SettingsPage"; -import SubscriptionsPage from "@/pages/subscriptions/SubscriptionsPage"; -import TaxPage from "@/pages/tax/TaxPage"; function PrivateRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); @@ -55,9 +53,7 @@ export default function App() { } /> } /> } /> - } /> } /> - } /> } /> diff --git a/frontend/src/api/subscriptions.ts b/frontend/src/api/subscriptions.ts deleted file mode 100644 index dcd45df..0000000 --- a/frontend/src/api/subscriptions.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { api } from "./client"; - -export interface Subscription { - name: string; - amount: number; - frequency: string; - next_expected: string | null; - last_paid: string | null; - account_id: string; - account_name: string | null; - transaction_ids: string[]; - latest_transaction_id: string; - monthly_equivalent: number; - confidence: number; - manually_set: boolean; -} - -export interface SubscriptionsSummary { - total_monthly_equivalent: number; - currency: string; - subscriptions: Subscription[]; -} - -export const getSubscriptions = (): Promise => - api.get("/subscriptions").then((r: { data: SubscriptionsSummary }) => r.data); - -export const triggerDetection = (): Promise<{ newly_tagged: number; total_recurring: number }> => - api.post("/transactions/detect-recurring").then((r: { data: { newly_tagged: number; total_recurring: number } }) => r.data); diff --git a/frontend/src/api/tax.ts b/frontend/src/api/tax.ts deleted file mode 100644 index 9905abe..0000000 --- a/frontend/src/api/tax.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { api } from "./client"; - -// --------------------------------------------------------------------------- -// Query key constants -// --------------------------------------------------------------------------- - -export const TAX_QUERY_KEYS = { - configuredYears: ["tax-configured-years"] as const, - rateConfig: (taxYear: number) => ["tax-rate-config", taxYear] as const, - profile: (taxYear: number) => ["tax-profile", taxYear] as const, - payslips: (taxYear: number) => ["tax-payslips", taxYear] as const, - cgtDisposals: (taxYear: number) => ["tax-cgt-disposals", taxYear] as const, - report: (taxYear: number) => ["tax-report", taxYear] as const, -}; - -// --------------------------------------------------------------------------- -// Interfaces -// --------------------------------------------------------------------------- - -export interface TaxRateConfig { - tax_year: number; - rates: { - income_tax?: { bands: RateBand[] }; - ni?: { bands: RateBand[] }; - cgt?: { exempt: number; basic_rate: number; higher_rate: number }; - dividend?: { - allowance: number; - basic_rate: number; - higher_rate: number; - additional_rate: number; - }; - }; - updated_at: string; -} - -export interface RateBand { - from: number; - to: number | null; - rate: number; -} - -export interface TaxRateConfigUpdate { - income_tax?: { bands: RateBand[] }; - ni?: { bands: RateBand[] }; - cgt?: { exempt: number; basic_rate: number; higher_rate: number }; - dividend?: { - allowance: number; - basic_rate: number; - higher_rate: number; - additional_rate: number; - }; -} - -export interface TaxProfile { - id: string; - tax_year: number; - tax_code: string; - employer_name: string | null; - is_cumulative: boolean; - created_at: string; - updated_at: string; -} - -export interface TaxProfileCreate { - tax_code?: string; - employer_name?: string | null; - is_cumulative?: boolean; -} - -export interface Payslip { - id: string; - tax_profile_id: string; - period_month: number | null; - period_year: number; - gross_pay: string; - income_tax_withheld: string; - ni_withheld: string; - net_pay: string; - is_p60: boolean; - notes: string | null; - created_at: string; -} - -export interface PayslipCreate { - period_month?: number | null; - period_year: number; - gross_pay: number; - income_tax_withheld: number; - ni_withheld: number; - net_pay: number; - notes?: string | null; -} - -export interface P60Entry { - gross_pay: number; - income_tax_withheld: number; - ni_withheld: number; - net_pay: number; -} - -export interface ManualDisposal { - id: string; - tax_year: number; - disposal_date: string; - asset_description: string; - proceeds: string; - cost_basis: string; - gain_loss: string; - notes: string | null; - created_at: string; -} - -export interface ManualDisposalCreate { - disposal_date: string; - asset_description: string; - proceeds: number; - cost_basis: number; - notes?: string | null; -} - -// --------------------------------------------------------------------------- -// Tax report nested types -// --------------------------------------------------------------------------- - -export interface BandBreakdown { - rate: number; - taxable: number; - tax: number; - from?: number; - to?: number | null; -} - -export interface InvestmentDisposalItem { - date: string; - asset: string; - symbol: string; - quantity: string; - proceeds: string; - cost_basis: string; - fees: string; - gain_loss: string; -} - -export interface DividendTransactionItem { - date: string; - asset: string; - symbol: string; - amount: string; -} - -export interface TaxReport { - tax_year: number; - tax_year_display: string; - profile: TaxProfile | null; - income: { - gross_income: string; - income_tax_withheld: string; - ni_withheld: string; - payslips: Payslip[]; - }; - income_tax: { - personal_allowance: string; - taxable_income: string; - liability: string; - band_breakdown: BandBreakdown[]; - withheld: string; - owed: string; - }; - ni: { - liability: string; - band_breakdown: BandBreakdown[]; - withheld: string; - owed: string; - }; - cgt: { - gross_gain: string; - exempt: string; - taxable_gain: string; - liability: string; - band_breakdown: BandBreakdown[]; - investment_disposals: InvestmentDisposalItem[]; - manual_disposals: ManualDisposal[]; - total_gain: string; - }; - dividends: { - gross_dividends: string; - allowance: string; - taxable_dividends: string; - liability: string; - band_breakdown: BandBreakdown[]; - dividend_transactions: DividendTransactionItem[]; - }; - summary: { - total_liability: string; - total_withheld: string; - net_owed: string; - overpaid: boolean; - }; -} - -// --------------------------------------------------------------------------- -// API functions — rate configs -// --------------------------------------------------------------------------- - -export async function getConfiguredYears(): Promise { - const res = await api.get("/tax/rate-configs"); - return res.data; -} - -export async function getRateConfig(taxYear: number): Promise { - const res = await api.get(`/tax/rate-configs/${taxYear}`); - return res.data; -} - -export async function upsertRateConfig( - taxYear: number, - data: TaxRateConfigUpdate -): Promise { - const res = await api.put(`/tax/rate-configs/${taxYear}`, data); - return res.data; -} - -// --------------------------------------------------------------------------- -// API functions — tax profile -// --------------------------------------------------------------------------- - -export async function getTaxProfile(taxYear: number): Promise { - const res = await api.get(`/tax/profile/${taxYear}`); - return res.data; -} - -export async function upsertTaxProfile( - taxYear: number, - data: TaxProfileCreate -): Promise { - const res = await api.put(`/tax/profile/${taxYear}`, data); - return res.data; -} - -// --------------------------------------------------------------------------- -// API functions — payslips -// --------------------------------------------------------------------------- - -export async function getPayslips(taxYear: number): Promise { - const res = await api.get(`/tax/payslips/${taxYear}`); - return res.data; -} - -export async function createPayslip( - taxYear: number, - data: PayslipCreate -): Promise { - const res = await api.post(`/tax/payslips/${taxYear}`, data); - return res.data; -} - -export async function updatePayslip( - id: string, - data: Partial -): Promise { - const res = await api.put(`/tax/payslips/${id}`, data); - return res.data; -} - -export async function deletePayslip(id: string): Promise { - await api.delete(`/tax/payslips/${id}`); -} - -export async function enterP60(taxYear: number, data: P60Entry): Promise { - await api.post(`/tax/payslips/${taxYear}/p60`, data); -} - -// --------------------------------------------------------------------------- -// API functions — manual CGT disposals -// --------------------------------------------------------------------------- - -export async function getCgtDisposals(taxYear: number): Promise { - const res = await api.get(`/tax/cgt-disposals/${taxYear}`); - return res.data; -} - -export async function createCgtDisposal( - taxYear: number, - data: ManualDisposalCreate -): Promise { - const res = await api.post(`/tax/cgt-disposals/${taxYear}`, data); - return res.data; -} - -export async function updateCgtDisposal( - id: string, - data: Partial -): Promise { - const res = await api.put(`/tax/cgt-disposals/${id}`, data); - return res.data; -} - -export async function deleteCgtDisposal(id: string): Promise { - await api.delete(`/tax/cgt-disposals/${id}`); -} - -// --------------------------------------------------------------------------- -// API functions — report -// --------------------------------------------------------------------------- - -export async function getTaxReport(taxYear: number): Promise { - const res = await api.get(`/tax/report/${taxYear}`); - return res.data; -} diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts index 95e66f2..b8ac710 100644 --- a/frontend/src/api/transactions.ts +++ b/frontend/src/api/transactions.ts @@ -25,7 +25,6 @@ export interface Transaction { notes: string | null; tags: string[]; is_recurring: boolean; - recurring_rule: Record | null; attachment_refs: AttachmentRef[]; created_at: string; updated_at: string; @@ -44,8 +43,6 @@ export interface TransactionCreate { merchant?: string; notes?: string; tags?: string[]; - is_recurring?: boolean; - recurring_rule?: Record | null; } export interface TransactionPage { diff --git a/frontend/src/components/layout/MobileNav.tsx b/frontend/src/components/layout/MobileNav.tsx index b99e97c..56d098e 100644 --- a/frontend/src/components/layout/MobileNav.tsx +++ b/frontend/src/components/layout/MobileNav.tsx @@ -2,20 +2,18 @@ import { Link, useLocation } from "react-router-dom"; import { cn } from "@/utils/cn"; import { LayoutDashboard, CreditCard, ArrowLeftRight, - PiggyBank, TrendingUp, BarChart3, Sparkles, Settings, Repeat, Receipt, + PiggyBank, TrendingUp, BarChart3, Sparkles, Settings, } from "lucide-react"; const NAV = [ - { href: "/", icon: LayoutDashboard, label: "Home" }, - { href: "/accounts", icon: CreditCard, label: "Accounts" }, - { href: "/transactions", icon: ArrowLeftRight, label: "Txns" }, - { href: "/subscriptions",icon: Repeat, label: "Recurring" }, - { href: "/budgets", icon: PiggyBank, label: "Budgets" }, - { href: "/investments", icon: TrendingUp, label: "Invest" }, - { href: "/reports", icon: BarChart3, label: "Reports" }, - { href: "/tax", icon: Receipt, label: "Tax" }, - { href: "/predictions", icon: Sparkles, label: "Predict" }, - { href: "/settings", icon: Settings, label: "Settings" }, + { href: "/", icon: LayoutDashboard, label: "Home" }, + { href: "/accounts", icon: CreditCard, label: "Accounts" }, + { href: "/transactions",icon: ArrowLeftRight, label: "Txns" }, + { href: "/budgets", icon: PiggyBank, label: "Budgets" }, + { href: "/investments", icon: TrendingUp, label: "Invest" }, + { href: "/reports", icon: BarChart3, label: "Reports" }, + { href: "/predictions", icon: Sparkles, label: "Predict" }, + { href: "/settings", icon: Settings, label: "Settings" }, ]; export default function MobileNav() { diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 35816a2..e890e82 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -13,19 +13,15 @@ import { ChevronLeft, ChevronRight, Coins, - Repeat, - Receipt, } from "lucide-react"; const navItems = [ { href: "/", icon: LayoutDashboard, label: "Dashboard" }, { href: "/accounts", icon: CreditCard, label: "Accounts" }, { href: "/transactions", icon: ArrowLeftRight, label: "Transactions" }, - { href: "/subscriptions", icon: Repeat, label: "Subscriptions" }, { href: "/budgets", icon: PiggyBank, label: "Budgets" }, { href: "/investments", icon: TrendingUp, label: "Investments" }, { href: "/reports", icon: BarChart3, label: "Reports" }, - { href: "/tax", icon: Receipt, label: "Tax" }, { href: "/predictions", icon: Sparkles, label: "Predictions" }, { href: "/settings", icon: Settings, label: "Settings" }, ]; diff --git a/frontend/src/index.css b/frontend/src/index.css index cb4d240..7ee97c9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -23,7 +23,6 @@ --ring: 252 87% 67%; --radius: 0.6rem; --success: 142 71% 45%; - --warning: 38 92% 58%; } /* ─── Base font ─────────────────────────────────────────────────────────────── */ @@ -72,7 +71,6 @@ --ring: 252 87% 67%; --radius: 0.6rem; --success: 142 71% 45%; - --warning: 38 92% 58%; } /* ══════════════════════════════════════════════════════════════════════════════ @@ -99,7 +97,6 @@ --ring: 252 87% 55%; --radius: 0.6rem; --success: 142 71% 40%; - --warning: 38 90% 46%; } /* ══════════════════════════════════════════════════════════════════════════════ @@ -126,7 +123,6 @@ --ring: 252 87% 70%; --radius: 0.5rem; --success: 142 71% 45%; - --warning: 38 92% 62%; } /* ══════════════════════════════════════════════════════════════════════════════ @@ -153,7 +149,6 @@ --ring: 38 92% 50%; --radius: 0.4rem; --success: 142 55% 42%; - --warning: 38 92% 58%; } /* ══════════════════════════════════════════════════════════════════════════════ @@ -180,7 +175,6 @@ --ring: 120 100% 50%; --radius: 0.2rem; --success: 120 100% 50%; - --warning: 60 100% 55%; } .theme-terminal body, @@ -249,7 +243,6 @@ --ring: 330 100% 62%; --radius: 0.5rem; --success: 165 100% 45%; - --warning: 55 100% 65%; } .theme-synthwave h1, @@ -314,7 +307,6 @@ --ring: 0 65% 38%; --radius: 0.2rem; --success: 142 50% 32%; - --warning: 38 90% 46%; } .theme-ledger body, diff --git a/frontend/src/pages/accounts/AccountDetail.tsx b/frontend/src/pages/accounts/AccountDetail.tsx index 771cbec..2ecc6d2 100644 --- a/frontend/src/pages/accounts/AccountDetail.tsx +++ b/frontend/src/pages/accounts/AccountDetail.tsx @@ -95,13 +95,13 @@ export default function AccountDetail() {
Credit Utilisation - 80 ? "text-destructive" : utilPct > 50 ? "text-warning" : "text-success")}> + 80 ? "text-destructive" : utilPct > 50 ? "text-yellow-500" : "text-success")}> {utilPct.toFixed(0)}%
80 ? "bg-destructive" : utilPct > 50 ? "bg-warning" : "bg-success")} + className={cn("h-full rounded-full transition-all", utilPct > 80 ? "bg-destructive" : utilPct > 50 ? "bg-yellow-500" : "bg-success")} style={{ width: `${utilPct}%` }} />
@@ -298,7 +298,7 @@ function ImportModal({ {/* Detected format badge */}
{preview.detected_format ? ( <> Detected: {preview.detected_format} diff --git a/frontend/src/pages/accounts/AccountList.tsx b/frontend/src/pages/accounts/AccountList.tsx index 45e0a00..b5a18e9 100644 --- a/frontend/src/pages/accounts/AccountList.tsx +++ b/frontend/src/pages/accounts/AccountList.tsx @@ -315,7 +315,7 @@ function AccountGroup({
80 ? "bg-destructive" : utilPct > 50 ? "bg-warning" : "bg-success")} + className={cn("h-full rounded-full transition-all", utilPct > 80 ? "bg-destructive" : utilPct > 50 ? "bg-yellow-500" : "bg-success")} style={{ width: `${utilPct}%` }} />
diff --git a/frontend/src/pages/budgets/BudgetPage.tsx b/frontend/src/pages/budgets/BudgetPage.tsx index 170cbca..3bf6d33 100644 --- a/frontend/src/pages/budgets/BudgetPage.tsx +++ b/frontend/src/pages/budgets/BudgetPage.tsx @@ -12,11 +12,7 @@ function RadialGauge({ percent, size = 80 }: { percent: number; size?: number }) const circumference = 2 * Math.PI * r; const clamped = Math.min(percent, 100); const offset = circumference - (clamped / 100) * circumference; - const color = percent >= 100 - ? "hsl(var(--destructive))" - : percent >= 80 - ? "hsl(var(--warning))" - : "hsl(var(--success))"; + const color = percent >= 100 ? "#ef4444" : percent >= 80 ? "#f97316" : "#22c55e"; return ( @@ -109,7 +105,7 @@ export default function BudgetPage() { · {overBudget} over budget )} {alerted > 0 && ( - · {alerted} near limit + · {alerted} near limit )}

@@ -143,7 +139,7 @@ export default function BudgetPage() { item.is_over_budget ? "border-destructive/50" : item.alert_triggered - ? "border-warning/50" + ? "border-orange-500/50" : "border-border" )} > @@ -174,7 +170,7 @@ export default function BudgetPage() { {item.is_over_budget ? ( ) : item.alert_triggered ? ( - + ) : ( )} diff --git a/frontend/src/pages/dashboard/Dashboard.tsx b/frontend/src/pages/dashboard/Dashboard.tsx index 2846bf5..a530470 100644 --- a/frontend/src/pages/dashboard/Dashboard.tsx +++ b/frontend/src/pages/dashboard/Dashboard.tsx @@ -18,20 +18,6 @@ import { Link } from "react-router-dom"; const COLORS = ["#6366f1","#22c55e","#f97316","#ec4899","#14b8a6","#f59e0b","#8b5cf6","#ef4444"]; -const TOOLTIP_STYLE = { - contentStyle: { - background: "hsl(var(--card))", - border: "1px solid hsl(var(--border))", - borderRadius: "8px", - fontSize: "12px", - color: "hsl(var(--foreground))", - boxShadow: "0 4px 12px rgba(0,0,0,0.15)", - }, - labelStyle: { color: "hsl(var(--foreground))", fontWeight: 500, marginBottom: "4px" }, - itemStyle: { color: "hsl(var(--muted-foreground))" }, - cursor: { fill: "hsl(var(--muted-foreground))", fillOpacity: 0.08 }, -}; - const TYPE_COLORS: Record = { income: "text-success", expense: "text-destructive", @@ -67,13 +53,13 @@ export default function Dashboard() { {/* 2FA nudge */} {!totpEnabled && ( -
- +
+

- Enable two-factor authentication + Enable two-factor authentication to secure your account.

- + Set up 2FA
@@ -125,14 +111,14 @@ export default function Dashboard() { ({ date: p.date, value: Number(p.net_worth) }))}> - - + + - - `£${(v/1000).toFixed(0)}k`} width={45} /> - formatCurrency(v, nwReport.base_currency)} /> - + + `£${(v/1000).toFixed(0)}k`} width={45} /> + formatCurrency(v, nwReport.base_currency)} /> + ) : ( @@ -150,9 +136,9 @@ export default function Dashboard() { {ieReport && ieReport.points.length > 0 ? ( ({ month: p.month, income: Number(p.income), expenses: Number(p.expenses) }))}> - - `£${(v/1000).toFixed(0)}k`} width={45} /> - formatCurrency(v, "GBP")} /> + + `£${(v/1000).toFixed(0)}k`} width={45} /> + formatCurrency(v, "GBP")} /> @@ -180,7 +166,7 @@ export default function Dashboard() { cx="50%" cy="50%" innerRadius={42} outerRadius={65} dataKey="value" paddingAngle={2}> {catReport.items.slice(0,8).map((_, i) => )} - formatCurrency(v, "GBP")} /> + formatCurrency(v, "GBP")} />
diff --git a/frontend/src/pages/investments/AssetDetail.tsx b/frontend/src/pages/investments/AssetDetail.tsx index d1827b5..9898267 100644 --- a/frontend/src/pages/investments/AssetDetail.tsx +++ b/frontend/src/pages/investments/AssetDetail.tsx @@ -6,7 +6,6 @@ import { useUiStore } from "@/store/uiStore"; import { cn } from "@/utils/cn"; import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react"; import Plot from "react-plotly.js"; -import { cssVar } from "@/utils/cssVar"; export default function AssetDetail() { const { assetId } = useParams<{ assetId: string }>(); @@ -153,8 +152,8 @@ export default function AssetDetail() { high: highs as number[], low: lows as number[], close: closes as number[], - increasing: { line: { color: cssVar("--success") } }, - decreasing: { line: { color: cssVar("--destructive") } }, + increasing: { line: { color: "#22c55e" } }, + decreasing: { line: { color: "#ef4444" } }, name: holding?.symbol ?? "Price", }, { @@ -162,16 +161,16 @@ export default function AssetDetail() { x: dates, y: volumes as number[], yaxis: "y2", - marker: { color: cssVar("--primary", 0.3) }, + marker: { color: "rgba(99,102,241,0.3)" }, name: "Volume", }, ]} layout={{ paper_bgcolor: "transparent", plot_bgcolor: "transparent", - font: { color: cssVar("--muted-foreground"), size: 11 }, - xaxis: { rangeslider: { visible: false }, gridcolor: cssVar("--border"), showgrid: true }, - yaxis: { gridcolor: cssVar("--border"), showgrid: true, domain: [0.25, 1] }, + font: { color: "var(--muted-foreground)", size: 11 }, + xaxis: { rangeslider: { visible: false }, gridcolor: "var(--border)", showgrid: true }, + yaxis: { gridcolor: "var(--border)", showgrid: true, domain: [0.25, 1] }, yaxis2: { domain: [0, 0.2], showgrid: false }, margin: { t: 10, r: 10, b: 40, l: 60 }, showlegend: false, diff --git a/frontend/src/pages/investments/PortfolioCharts.tsx b/frontend/src/pages/investments/PortfolioCharts.tsx index 9f5ee35..d496afd 100644 --- a/frontend/src/pages/investments/PortfolioCharts.tsx +++ b/frontend/src/pages/investments/PortfolioCharts.tsx @@ -10,20 +10,6 @@ const COLORS = [ "#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444", ]; -const TOOLTIP_STYLE = { - contentStyle: { - background: "hsl(var(--card))", - border: "1px solid hsl(var(--border))", - borderRadius: "8px", - fontSize: "12px", - color: "hsl(var(--foreground))", - boxShadow: "0 4px 12px rgba(0,0,0,0.15)", - }, - labelStyle: { color: "hsl(var(--foreground))", fontWeight: 500, marginBottom: "4px" }, - itemStyle: { color: "hsl(var(--muted-foreground))" }, - cursor: { fill: "hsl(var(--muted-foreground))", fillOpacity: 0.08 }, -}; - const TYPE_COLORS: Record = { stock: "#6366f1", etf: "#22c55e", @@ -195,11 +181,16 @@ export function CostVsValueChart({ portfolio }: { portfolio: PortfolioSummary }) width={52} /> [ formatCurrency(value, portfolio.currency), name === "cost" ? "Cost basis" : "Current value", ]} + contentStyle={{ + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + fontSize: "12px", + }} /> @@ -274,8 +265,13 @@ export function ReturnChart({ portfolio }: { portfolio: PortfolioSummary }) { /> [`${Number(value).toFixed(2)}%`, "Return"]} + contentStyle={{ + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + fontSize: "12px", + }} /> - - `£${v}`} width={55} /> + + `£${v}`} width={55} /> formatCurrency(v, "GBP")} /> - - + + @@ -147,13 +146,13 @@ function BudgetAlerts() {
{f.category_name} - 0.75 ? "text-destructive" : "text-warning")}> + 0.75 ? "text-destructive" : "text-yellow-500")}> {(f.probability_overspend * 100).toFixed(0)}% overspend risk
0.75 ? "bg-destructive" : "bg-warning")} + className={cn("h-full rounded-full", f.probability_overspend > 0.75 ? "bg-destructive" : "bg-yellow-500")} style={{ width: `${Math.min(100, forecastPct)}%` }} />
@@ -239,12 +238,12 @@ function NetWorthTab() {

Net Worth Projection

- - `£${(v / 1000).toFixed(0)}k`} width={55} /> + + `£${(v / 1000).toFixed(0)}k`} width={55} /> formatCurrency(v, "GBP")} /> - {lastHistory && } - + {lastHistory && } + @@ -343,8 +342,8 @@ function MonteCarloTab() { x: data.percentiles.p90.map(p => p.date), y: data.percentiles.p90.map(p => p.value), fill: "tonexty", - fillcolor: cssVar("--primary", 0.15), - line: { color: cssVar("--primary"), width: 1 }, + fillcolor: "rgba(99,102,241,0.15)", + line: { color: "#6366f1", width: 1 }, name: "P90", mode: "lines", }, @@ -353,8 +352,8 @@ function MonteCarloTab() { x: data.percentiles.p75.map(p => p.date), y: data.percentiles.p75.map(p => p.value), fill: "tonexty", - fillcolor: cssVar("--primary", 0.2), - line: { color: cssVar("--primary"), width: 1 }, + fillcolor: "rgba(99,102,241,0.2)", + line: { color: "#6366f1", width: 1 }, name: "P75", mode: "lines", }, @@ -362,7 +361,7 @@ function MonteCarloTab() { type: "scatter" as const, x: data.percentiles.p50.map(p => p.date), y: data.percentiles.p50.map(p => p.value), - line: { color: cssVar("--success"), width: 2.5 }, + line: { color: "#22c55e", width: 2.5 }, name: "P50 (Median)", mode: "lines", }, @@ -371,8 +370,8 @@ function MonteCarloTab() { x: data.percentiles.p25.map(p => p.date), y: data.percentiles.p25.map(p => p.value), fill: "tonexty", - fillcolor: cssVar("--destructive", 0.1), - line: { color: cssVar("--destructive"), width: 1 }, + fillcolor: "rgba(239,68,68,0.1)", + line: { color: "#ef4444", width: 1 }, name: "P25", mode: "lines", }, @@ -381,8 +380,8 @@ function MonteCarloTab() { x: data.percentiles.p10.map(p => p.date), y: data.percentiles.p10.map(p => p.value), fill: "tonexty", - fillcolor: cssVar("--destructive", 0.15), - line: { color: cssVar("--destructive"), width: 1 }, + fillcolor: "rgba(239,68,68,0.15)", + line: { color: "#ef4444", width: 1 }, name: "P10", mode: "lines", }, @@ -390,10 +389,10 @@ function MonteCarloTab() { layout={{ paper_bgcolor: "transparent", plot_bgcolor: "transparent", - font: { color: cssVar("--muted-foreground"), size: 11 }, - xaxis: { gridcolor: cssVar("--border"), showgrid: true }, + font: { color: "var(--muted-foreground)", size: 11 }, + xaxis: { gridcolor: "var(--border)", showgrid: true }, yaxis: { - gridcolor: cssVar("--border"), + gridcolor: "var(--border)", showgrid: true, tickformat: "£,.0f", }, @@ -462,15 +461,15 @@ function CashFlowTab() { - - + + - v.slice(5)} /> - `£${(v / 1000).toFixed(1)}k`} width={55} /> + v.slice(5)} /> + `£${(v / 1000).toFixed(1)}k`} width={55} /> formatCurrency(v, "GBP")} /> - - + +
diff --git a/frontend/src/pages/reports/ReportsPage.tsx b/frontend/src/pages/reports/ReportsPage.tsx index e6bd75e..fa2abf3 100644 --- a/frontend/src/pages/reports/ReportsPage.tsx +++ b/frontend/src/pages/reports/ReportsPage.tsx @@ -37,20 +37,6 @@ const COLORS = [ const ASSET_COLORS = ["#22c55e", "#14b8a6", "#6366f1", "#8b5cf6", "#06b6d4", "#84cc16"]; const LIABILITY_COLORS = ["#ef4444", "#f97316", "#ec4899"]; -const TOOLTIP_STYLE = { - contentStyle: { - background: "hsl(var(--card))", - border: "1px solid hsl(var(--border))", - borderRadius: "8px", - fontSize: "12px", - color: "hsl(var(--foreground))", - boxShadow: "0 4px 12px rgba(0,0,0,0.15)", - }, - labelStyle: { color: "hsl(var(--foreground))", fontWeight: 500, marginBottom: "4px" }, - itemStyle: { color: "hsl(var(--muted-foreground))" }, - cursor: { fill: "hsl(var(--muted-foreground))", fillOpacity: 0.08 }, -}; - function StatCard({ label, value, change, currency }: { label: string; value: number; change?: number; currency: string; }) { @@ -263,15 +249,15 @@ function NetWorthTab() { ({ ...p, net_worth: Number(p.net_worth), total_assets: Number(p.total_assets), total_liabilities: Number(p.total_liabilities) }))}> - - + + - - - `£${(v/1000).toFixed(0)}k`} /> - formatCurrency(v, data.base_currency)} /> - + + + `£${(v/1000).toFixed(0)}k`} /> + formatCurrency(v, data.base_currency)} /> + @@ -301,10 +287,10 @@ function IncomeExpenseTab() {

Monthly Income vs Expenses

- - - `£${(v/1000).toFixed(0)}k`} /> - formatCurrency(v, data.currency)} /> + + + `£${(v/1000).toFixed(0)}k`} /> + formatCurrency(v, data.currency)} /> @@ -354,15 +340,15 @@ function CashFlowTab() {

Daily Cash Flow — Last 30 Days

- - - `£${(v/1000).toFixed(1)}k`} /> - `£${(v/1000).toFixed(1)}k`} /> - formatCurrency(v, data.currency)} /> + + + `£${(v/1000).toFixed(1)}k`} /> + `£${(v/1000).toFixed(1)}k`} /> + formatCurrency(v, data.currency)} /> - +
@@ -418,15 +404,15 @@ function SavingsRateTab() {

Savings Rate by Month

- - - `£${(v/1000).toFixed(0)}k`} /> - `${v}%`} /> - name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} /> + + + `£${(v/1000).toFixed(0)}k`} /> + `${v}%`} /> + name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} /> - +
@@ -502,7 +488,7 @@ function CategoriesTab() { /> ))} - formatCurrency(v, data.currency)} /> + formatCurrency(v, data.currency)} />
@@ -594,12 +580,12 @@ function BudgetVsActualTab() {

Budget vs Actual Spending

- - `£${v}`} /> - - formatCurrency(v, data.currency)} /> + + `£${v}`} /> + + formatCurrency(v, data.currency)} /> - + @@ -628,10 +614,10 @@ function SpendingTrendsTab() {

Spending by Category (6 months)

- - - `£${v}`} /> - formatCurrency(v, data.currency)} /> + + + `£${v}`} /> + formatCurrency(v, data.currency)} /> {data.categories.slice(0, 8).map((cat, i) => ( @@ -688,10 +674,10 @@ function InvestmentsTab() {

Holdings Value

- - `£${(v/1000).toFixed(0)}k`} /> - - formatCurrency(v, perf.currency)} /> + + `£${(v/1000).toFixed(0)}k`} /> + + formatCurrency(v, perf.currency)} /> {holdingsData.map((entry, i) => ( = 0 ? "#22c55e" : "#ef4444"} /> diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 9e4dd9d..f8941c7 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -234,7 +234,7 @@ function PasswordCard() {
{[1,2,3,4].map(i => { const score = Math.min(4, Math.floor(next.length / 3)); - return
; + return
; })}
)} diff --git a/frontend/src/pages/subscriptions/SubscriptionsPage.tsx b/frontend/src/pages/subscriptions/SubscriptionsPage.tsx deleted file mode 100644 index f2ba7a8..0000000 --- a/frontend/src/pages/subscriptions/SubscriptionsPage.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getSubscriptions, triggerDetection } from "@/api/subscriptions"; -import { updateTransaction } from "@/api/transactions"; -import type { Subscription } from "@/api/subscriptions"; -import { formatCurrency } from "@/utils/currency"; -import { cn } from "@/utils/cn"; -import { format, parseISO, differenceInDays } from "date-fns"; -import { - RefreshCw, Loader2, CalendarClock, AlertCircle, Ban, -} from "lucide-react"; - -type SortKey = "next" | "amount" | "name"; - -function nextBadge(nextExpected: string | null) { - if (!nextExpected) return null; - const days = differenceInDays(parseISO(nextExpected), new Date()); - - if (days < 0) { - return ( - - Overdue - - ); - } - if (days === 0) { - return ( - - Today - - ); - } - if (days <= 7) { - return ( - - {days}d - - ); - } - return ( - - {format(parseISO(nextExpected), "d MMM")} - - ); -} - -function FrequencyBadge({ frequency }: { frequency: string }) { - const labels: Record = { - weekly: "Weekly", - fortnightly: "Fortnightly", - monthly: "Monthly", - quarterly: "Quarterly", - yearly: "Yearly", - unknown: "Unknown", - }; - return ( - - {labels[frequency] ?? frequency} - - ); -} - -function SubscriptionRow({ - sub, - onUnmark, -}: { - sub: Subscription; - onUnmark: (id: string) => void; -}) { - const [menuOpen, setMenuOpen] = useState(false); - - return ( -
- {/* Icon */} -
- -
- - {/* Name + account */} -
-

{sub.name}

-
- - {sub.account_name && ( - {sub.account_name} - )} - {sub.manually_set && ( - manual - )} -
-
- - {/* Last paid */} -
-

Last paid

-

{sub.last_paid ? format(parseISO(sub.last_paid), "d MMM yyyy") : "—"}

-
- - {/* Next expected */} -
-

Next

- {nextBadge(sub.next_expected)} -
- - {/* Amount */} -
-

- {formatCurrency(Math.abs(sub.amount), "GBP")} -

-

- {formatCurrency(sub.monthly_equivalent, "GBP")}/mo -

-
- - {/* Actions */} -
- - {menuOpen && ( - <> -
setMenuOpen(false)} /> -
- -
- - )} -
-
- ); -} - -export default function SubscriptionsPage() { - const qc = useQueryClient(); - const [sort, setSort] = useState("next"); - const [rescanMsg, setRescanMsg] = useState(null); - - const { data, isLoading } = useQuery({ - queryKey: ["subscriptions"], - queryFn: getSubscriptions, - }); - - const rescanMutation = useMutation({ - mutationFn: triggerDetection, - onSuccess: (result) => { - qc.invalidateQueries({ queryKey: ["subscriptions"] }); - qc.invalidateQueries({ queryKey: ["transactions"] }); - setRescanMsg(`Done — ${result.newly_tagged} newly tagged, ${result.total_recurring} total recurring.`); - setTimeout(() => setRescanMsg(null), 4000); - }, - }); - - const unmarkMutation = useMutation({ - mutationFn: (txnId: string) => - updateTransaction(txnId, { - is_recurring: false, - recurring_rule: { manually_set: true }, - }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["subscriptions"] }); - qc.invalidateQueries({ queryKey: ["transactions"] }); - }, - }); - - const sorted = [...(data?.subscriptions ?? [])].sort((a, b) => { - if (sort === "next") { - return (a.next_expected ?? "9999") < (b.next_expected ?? "9999") ? -1 : 1; - } - if (sort === "amount") return Math.abs(b.amount) - Math.abs(a.amount); - return a.name.localeCompare(b.name); - }); - - return ( -
- {/* Header */} -
-
-

Subscriptions

-

- Direct debits, standing orders, and recurring payments -

-
- -
- - {rescanMsg && ( -
- {rescanMsg} -
- )} - - {/* Summary card */} - {data && ( -
-

Estimated monthly recurring spend

-

- {formatCurrency(data.total_monthly_equivalent, data.currency)} -

-

- {data.subscriptions.length} recurring payment{data.subscriptions.length !== 1 ? "s" : ""} detected -

-
- )} - - {/* Sort */} - {(data?.subscriptions.length ?? 0) > 0 && ( -
- Sort: - {(["next", "amount", "name"] as SortKey[]).map((key) => ( - - ))} -
- )} - - {/* List */} -
- {isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- ))} -
- ) : sorted.length === 0 ? ( -
- -

No recurring transactions detected

-

Import a few months of bank statements, then hit Re-scan.

-
- ) : ( - sorted.map((sub) => ( - unmarkMutation.mutate(id)} - /> - )) - )} -
-
- ); -} diff --git a/frontend/src/pages/tax/CGTSection.tsx b/frontend/src/pages/tax/CGTSection.tsx deleted file mode 100644 index 5522baf..0000000 --- a/frontend/src/pages/tax/CGTSection.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Plus, Pencil, Trash2, Info } from "lucide-react"; -import { - TAX_QUERY_KEYS, - createCgtDisposal, - updateCgtDisposal, - deleteCgtDisposal, - type TaxReport, - type ManualDisposal, - type ManualDisposalCreate, -} from "@/api/tax"; -import ManualDisposalFormModal from "./ManualDisposalFormModal"; - -function gbp(v: string | number) { - return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); -} - -function fmtDate(iso: string) { - return new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); -} - -function pct(r: number) { - return `${(r * 100 % 1 === 0 ? (r * 100).toFixed(0) : (r * 100).toFixed(1))}%`; -} - -function GainCell({ v }: { v: string }) { - const n = Number(v); - return ( - 0 ? "text-green-500" : n < 0 ? "text-destructive" : ""}`}> - {gbp(v)} - - ); -} - -interface Props { - taxYear: number; - report: TaxReport; -} - -export default function CGTSection({ taxYear, report }: Props) { - const qc = useQueryClient(); - const cgt = report.cgt; - - const [showAdd, setShowAdd] = useState(false); - const [editing, setEditing] = useState(null); - const [deletingId, setDeletingId] = useState(null); - - function invalidate() { - qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) }); - } - - const createMut = useMutation({ - mutationFn: (data: ManualDisposalCreate) => createCgtDisposal(taxYear, data), - onSuccess: () => { invalidate(); setShowAdd(false); }, - }); - - const updateMut = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Partial }) => - updateCgtDisposal(id, data), - onSuccess: () => { invalidate(); setEditing(null); }, - }); - - const deleteMut = useMutation({ - mutationFn: (id: string) => deleteCgtDisposal(id), - onSuccess: () => { invalidate(); setDeletingId(null); }, - }); - - return ( - <> -
-

Capital Gains Tax

- - {/* Mid-year rate change note for 2024/25 */} - {taxYear === 2025 && ( -
- - - 2024/25 note: The October 2024 Budget raised CGT rates mid-year (from 30 Oct 2024: 18% basic, 24% higher, up from 10%/20%). This tool uses a single set of rates for the whole year — disposals before and after 30 Oct may be blended. Verify any significant gains against HMRC guidance. - -
- )} - - {/* Summary */} -
- {[ - { label: "Gross Gain", value: cgt.gross_gain }, - { label: "Annual Exempt", value: cgt.exempt }, - { label: "Taxable Gain", value: cgt.taxable_gain }, - { label: "CGT Liability", value: cgt.liability, bold: true }, - ].map(({ label, value, bold }) => ( -
-

{label}

-

{gbp(value)}

-
- ))} -
- - {/* Band breakdown */} - {cgt.band_breakdown.length > 0 && ( -
-

Band Breakdown

- - - - - - - - - - {cgt.band_breakdown.map((b, i) => ( - - - - - - ))} - -
RateTaxableTax
{pct(b.rate)}{gbp(String(b.taxable))}{gbp(String(b.tax))}
-
- )} - - {/* Auto-detected disposals */} -
-

- Investment Disposals (auto-detected) -

- {cgt.investment_disposals.length === 0 ? ( -

No investment disposals detected for this tax year.

- ) : ( -
- - - - - - - - - - - - {cgt.investment_disposals.map((d, i) => ( - - - - - - - - ))} - -
DateAssetProceedsCost BasisGain / Loss
{fmtDate(d.date)} -

{d.asset}

-

{d.symbol} · {d.quantity} units

-
{gbp(d.proceeds)}{gbp(d.cost_basis)}
-
- )} -
- - {/* Manual disposals */} -
-
-

- Manual Disposals -

- -
- - {cgt.manual_disposals.length === 0 ? ( -

No manual disposals — add any non-investment capital gains here (e.g. property).

- ) : ( -
- - - - - - - - - - - - - {cgt.manual_disposals.map(d => ( - - - - - - - - - ))} - -
DateAssetProceedsCostGain / LossActions
{fmtDate(d.disposal_date)}{d.asset_description}{gbp(d.proceeds)}{gbp(d.cost_basis)} - {deletingId === d.id ? ( - - Delete? - - - - ) : ( - - - - - )} -
-
- )} -
-
- - {showAdd && ( - setShowAdd(false)} - onSubmit={data => createMut.mutate(data)} - isLoading={createMut.isPending} - /> - )} - - {editing && ( - setEditing(null)} - onSubmit={data => updateMut.mutate({ id: editing.id, data })} - isLoading={updateMut.isPending} - /> - )} - - ); -} diff --git a/frontend/src/pages/tax/DividendSection.tsx b/frontend/src/pages/tax/DividendSection.tsx deleted file mode 100644 index 3da22f0..0000000 --- a/frontend/src/pages/tax/DividendSection.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { type TaxReport } from "@/api/tax"; - -function gbp(v: string) { - return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); -} - -function pct(r: number) { - return `${(r * 100 % 1 === 0 ? (r * 100).toFixed(0) : (r * 100).toFixed(1))}%`; -} - -function fmtDate(iso: string) { - return new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); -} - -interface Props { - report: TaxReport; -} - -export default function DividendSection({ report }: Props) { - const div = report.dividends; - - return ( -
-

Dividends

- - {/* Summary */} -
- {[ - { label: "Gross Dividends", value: div.gross_dividends }, - { label: "Allowance", value: div.allowance }, - { label: "Taxable", value: div.taxable_dividends }, - { label: "Liability", value: div.liability, bold: true }, - ].map(({ label, value, bold }) => ( -
-

{label}

-

{gbp(value)}

-
- ))} -
- - {/* Band breakdown */} - {div.band_breakdown.length > 0 && ( -
-

Band Breakdown

- - - - - - - - - - {div.band_breakdown.map((b, i) => ( - - - - - - ))} - -
RateTaxableTax
{pct(b.rate)}{gbp(String(b.taxable))}{gbp(String(b.tax))}
-
- )} - - {/* Dividend transactions */} -
-

- Dividend Transactions (auto-detected) -

- {div.dividend_transactions.length === 0 ? ( -

No dividend income detected for this tax year.

- ) : ( -
- - - - - - - - - - {div.dividend_transactions.map((t, i) => ( - - - - - - ))} - -
DateAssetAmount
{fmtDate(t.date)} -

{t.asset}

-

{t.symbol}

-
{gbp(t.amount)}
-
- )} -
-
- ); -} diff --git a/frontend/src/pages/tax/ManualDisposalFormModal.tsx b/frontend/src/pages/tax/ManualDisposalFormModal.tsx deleted file mode 100644 index cbff42d..0000000 --- a/frontend/src/pages/tax/ManualDisposalFormModal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState } from "react"; -import { X, Loader2 } from "lucide-react"; -import { type ManualDisposal, type ManualDisposalCreate } from "@/api/tax"; - -interface Props { - disposal?: ManualDisposal; - onClose: () => void; - onSubmit: (data: ManualDisposalCreate) => void; - isLoading: boolean; -} - -export default function ManualDisposalFormModal({ disposal, onClose, onSubmit, isLoading }: Props) { - const isEdit = !!disposal; - - const [form, setForm] = useState({ - disposal_date: disposal?.disposal_date ?? "", - asset_description: disposal?.asset_description ?? "", - proceeds: disposal ? String(Number(disposal.proceeds)) : "", - cost_basis: disposal ? String(Number(disposal.cost_basis)) : "", - notes: disposal?.notes ?? "", - }); - const [error, setError] = useState(null); - - function set(key: string, value: string) { - setForm(f => ({ ...f, [key]: value })); - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!form.disposal_date) { setError("Disposal date is required"); return; } - if (!form.asset_description.trim()) { setError("Asset description is required"); return; } - const proceeds = parseFloat(form.proceeds); - const cost = parseFloat(form.cost_basis); - if (isNaN(proceeds) || proceeds < 0) { setError("Enter valid proceeds"); return; } - if (isNaN(cost) || cost < 0) { setError("Enter valid cost basis"); return; } - setError(null); - onSubmit({ - disposal_date: form.disposal_date, - asset_description: form.asset_description.trim(), - proceeds, - cost_basis: cost, - notes: form.notes.trim() || null, - }); - } - - const inp = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; - - return ( -
-
-
-

{isEdit ? "Edit Disposal" : "Add Manual Disposal"}

- -
- -
-
-
- - set("disposal_date", e.target.value)} className={inp} /> -
-
- - set("asset_description", e.target.value)} className={inp} placeholder="e.g. Rental property" /> -
-
- -
-
- - set("proceeds", e.target.value)} className={inp} placeholder="0.00" /> -
-
- - set("cost_basis", e.target.value)} className={inp} placeholder="0.00" /> -
-
- - {form.proceeds && form.cost_basis && !isNaN(parseFloat(form.proceeds)) && !isNaN(parseFloat(form.cost_basis)) && ( -

- Gain / Loss:{" "} - = 0 ? "text-green-500" : "text-destructive"}> - {new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format( - parseFloat(form.proceeds) - parseFloat(form.cost_basis) - )} - -

- )} - -
- - set("notes", e.target.value)} className={inp} placeholder="Optional" /> -
- - {error &&

{error}

} - -
- - -
-
-
-
- ); -} diff --git a/frontend/src/pages/tax/OverallLiabilityCard.tsx b/frontend/src/pages/tax/OverallLiabilityCard.tsx deleted file mode 100644 index 7c43b90..0000000 --- a/frontend/src/pages/tax/OverallLiabilityCard.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { type TaxReport } from "@/api/tax"; -import { taxYearDisplay } from "./TaxYearSelector"; - -function gbp(v: string) { - return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); -} - -interface Props { - report: TaxReport; -} - -export default function OverallLiabilityCard({ report }: Props) { - const s = report.summary; - const net = Number(s.net_owed); - const isZero = net === 0; - - const netColor = isZero - ? "text-muted-foreground" - : s.overpaid - ? "text-green-500" - : "text-yellow-500"; - - const netLabel = isZero ? "Balanced" : s.overpaid ? "Refund due" : "Additional tax owed"; - - const breakdown = [ - { label: "Income Tax", value: report.income_tax.liability }, - { label: "National Insurance", value: report.ni.liability }, - { label: "Capital Gains Tax", value: report.cgt.liability }, - { label: "Dividend Tax", value: report.dividends.liability }, - ]; - - return ( -
-

Overall Tax Position — {taxYearDisplay(report.tax_year)}

- -
- {/* Big numbers */} -
-
-

Total Liability

-

{gbp(s.total_liability)}

-
-
-

Already Withheld

-

{gbp(s.total_withheld)}

-
-
-

{netLabel}

-

{gbp(s.net_owed)}

- {!isZero && ( -

- {s.overpaid - ? "HMRC should refund this amount (verify via self-assessment or payroll)" - : "You may owe this via self-assessment — verify with HMRC"} -

- )} -
-
- - {/* Liability breakdown */} -
-

Breakdown

- {breakdown.map(({ label, value }) => ( -
- {label} - {gbp(value)} -
- ))} -
- Total - {gbp(s.total_liability)} -
-
-
-
- ); -} diff --git a/frontend/src/pages/tax/P60Modal.tsx b/frontend/src/pages/tax/P60Modal.tsx deleted file mode 100644 index 54b20f2..0000000 --- a/frontend/src/pages/tax/P60Modal.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState } from "react"; -import { X, Loader2, AlertTriangle } from "lucide-react"; -import { type P60Entry } from "@/api/tax"; - -interface Props { - existingCount: number; - onClose: () => void; - onSubmit: (data: P60Entry) => void; - isLoading: boolean; -} - -export default function P60Modal({ existingCount, onClose, onSubmit, isLoading }: Props) { - const [form, setForm] = useState({ - gross_pay: "", - income_tax_withheld: "", - ni_withheld: "", - net_pay: "", - }); - const [confirmed, setConfirmed] = useState(false); - const [error, setError] = useState(null); - - function set(key: string, value: string) { - setForm(f => ({ ...f, [key]: value })); - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (existingCount > 0 && !confirmed) { setError("Please confirm you want to replace existing payslips"); return; } - const gross = parseFloat(form.gross_pay); - const tax = parseFloat(form.income_tax_withheld); - const ni = parseFloat(form.ni_withheld); - const net = parseFloat(form.net_pay); - if (isNaN(gross) || gross < 0) { setError("Enter a valid gross pay"); return; } - if (isNaN(tax) || tax < 0) { setError("Enter a valid income tax"); return; } - if (isNaN(ni) || ni < 0) { setError("Enter a valid NI amount"); return; } - if (isNaN(net) || net < 0) { setError("Enter a valid net pay"); return; } - setError(null); - onSubmit({ gross_pay: gross, income_tax_withheld: tax, ni_withheld: ni, net_pay: net }); - } - - const inp = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; - - return ( -
-
-
-

Enter P60 Totals

- -
- -
-

- Enter the year-end totals from your P60. These are cumulative figures for the full tax year. -

- - {existingCount > 0 && ( -
- - - This will permanently replace your {existingCount} existing payslip{existingCount !== 1 ? "s" : ""} for this tax year with a single P60 entry. - -
- )} - -
- - set("gross_pay", e.target.value)} className={inp} placeholder="0.00" /> -
- -
-
- - set("income_tax_withheld", e.target.value)} className={inp} placeholder="0.00" /> -
-
- - set("ni_withheld", e.target.value)} className={inp} placeholder="0.00" /> -
-
- -
- - set("net_pay", e.target.value)} className={inp} placeholder="0.00" /> -
- - {existingCount > 0 && ( - - )} - - {error &&

{error}

} - -
- - -
-
-
-
- ); -} diff --git a/frontend/src/pages/tax/PayslipFormModal.tsx b/frontend/src/pages/tax/PayslipFormModal.tsx deleted file mode 100644 index 25e13c6..0000000 --- a/frontend/src/pages/tax/PayslipFormModal.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useState } from "react"; -import { X, Loader2 } from "lucide-react"; -import { type Payslip, type PayslipCreate } from "@/api/tax"; - -const MONTHS = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December", -]; - -interface Props { - taxYear: number; - payslip?: Payslip; - onClose: () => void; - onSubmit: (data: PayslipCreate) => void; - isLoading: boolean; -} - -export default function PayslipFormModal({ taxYear, payslip, onClose, onSubmit, isLoading }: Props) { - const isEdit = !!payslip; - - const [form, setForm] = useState({ - period_month: payslip?.period_month ?? 4, - period_year: payslip?.period_year ?? taxYear - 1, - gross_pay: payslip ? payslip.gross_pay : "", - income_tax_withheld: payslip ? payslip.income_tax_withheld : "", - ni_withheld: payslip ? payslip.ni_withheld : "", - net_pay: payslip ? payslip.net_pay : "", - notes: payslip?.notes ?? "", - }); - - const [error, setError] = useState(null); - - function set(key: string, value: string | number) { - setForm(f => ({ ...f, [key]: value })); - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const gross = parseFloat(form.gross_pay); - const tax = parseFloat(form.income_tax_withheld); - const ni = parseFloat(form.ni_withheld); - const net = parseFloat(form.net_pay); - if (isNaN(gross) || gross < 0) { setError("Enter a valid gross pay amount"); return; } - if (isNaN(tax) || tax < 0) { setError("Enter a valid income tax amount"); return; } - if (isNaN(ni) || ni < 0) { setError("Enter a valid NI amount"); return; } - if (isNaN(net) || net < 0) { setError("Enter a valid net pay amount"); return; } - setError(null); - onSubmit({ - period_month: Number(form.period_month), - period_year: Number(form.period_year), - gross_pay: gross, - income_tax_withheld: tax, - ni_withheld: ni, - net_pay: net, - notes: form.notes.trim() || null, - }); - } - - const inp = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; - - return ( -
-
-
-

{isEdit ? "Edit Payslip" : "Add Payslip"}

- -
- -
-
-
- - -
-
- - set("period_year", e.target.value)} - className={inp} - placeholder="2024" - /> -
-
- -
- - set("gross_pay", e.target.value)} - className={inp} - placeholder="0.00" - /> -
- -
-
- - set("income_tax_withheld", e.target.value)} - className={inp} - placeholder="0.00" - /> -
-
- - set("ni_withheld", e.target.value)} - className={inp} - placeholder="0.00" - /> -
-
- -
- - set("net_pay", e.target.value)} - className={inp} - placeholder="0.00" - /> -
- -
- - set("notes", e.target.value)} - className={inp} - placeholder="Optional" - /> -
- - {error &&

{error}

} - -
- - -
-
-
-
- ); -} diff --git a/frontend/src/pages/tax/PayslipTable.tsx b/frontend/src/pages/tax/PayslipTable.tsx deleted file mode 100644 index c15e644..0000000 --- a/frontend/src/pages/tax/PayslipTable.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plus, FileText, Pencil, Trash2, Loader2 } from "lucide-react"; -import { - TAX_QUERY_KEYS, - getPayslips, - createPayslip, - updatePayslip, - deletePayslip, - enterP60, - type Payslip, - type PayslipCreate, - type P60Entry, -} from "@/api/tax"; -import PayslipFormModal from "./PayslipFormModal"; -import P60Modal from "./P60Modal"; - -const MONTH_ABBR = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; - -function gbp(v: string) { - return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); -} - -function monthLabel(p: Payslip) { - if (p.is_p60) return "P60"; - if (p.period_month) return `${MONTH_ABBR[p.period_month - 1]} ${p.period_year}`; - return `${p.period_year}`; -} - -interface Props { - taxYear: number; -} - -export default function PayslipTable({ taxYear }: Props) { - const qc = useQueryClient(); - - const { data: payslips = [], isLoading } = useQuery({ - queryKey: TAX_QUERY_KEYS.payslips(taxYear), - queryFn: () => getPayslips(taxYear), - }); - - const [showAdd, setShowAdd] = useState(false); - const [editing, setEditing] = useState(null); - const [showP60, setShowP60] = useState(false); - const [deletingId, setDeletingId] = useState(null); - - function invalidate() { - qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.payslips(taxYear) }); - qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) }); - } - - const createMut = useMutation({ - mutationFn: (data: PayslipCreate) => createPayslip(taxYear, data), - onSuccess: () => { invalidate(); setShowAdd(false); }, - }); - - const updateMut = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Partial }) => updatePayslip(id, data), - onSuccess: () => { invalidate(); setEditing(null); }, - }); - - const deleteMut = useMutation({ - mutationFn: (id: string) => deletePayslip(id), - onSuccess: () => { invalidate(); setDeletingId(null); }, - }); - - const p60Mut = useMutation({ - mutationFn: (data: P60Entry) => enterP60(taxYear, data), - onSuccess: () => { invalidate(); setShowP60(false); }, - }); - - // Totals - const totals = payslips.reduce( - (acc, p) => ({ - gross: acc.gross + Number(p.gross_pay), - tax: acc.tax + Number(p.income_tax_withheld), - ni: acc.ni + Number(p.ni_withheld), - net: acc.net + Number(p.net_pay), - }), - { gross: 0, tax: 0, ni: 0, net: 0 } - ); - - function fmtNum(n: number) { - return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(n); - } - - return ( - <> -
-
-

Payslips

-
- - -
-
- - {isLoading ? ( -
- - Loading… -
- ) : payslips.length === 0 ? ( -
- No payslips yet — add a payslip or enter P60 totals. -
- ) : ( -
- - - - - - - - - - - - - {payslips.map(p => ( - - - - - - - - - ))} - - {payslips.length > 1 && ( - - - - - - - - - - )} -
PeriodGrossIncome TaxNINet PayActions
- {monthLabel(p)} - {p.is_p60 && ( - P60 - )} - {gbp(p.gross_pay)}{gbp(p.income_tax_withheld)}{gbp(p.ni_withheld)}{gbp(p.net_pay)} - {deletingId === p.id ? ( -
- Delete? - - -
- ) : ( -
- {!p.is_p60 && ( - - )} - -
- )} -
Total{fmtNum(totals.gross)}{fmtNum(totals.tax)}{fmtNum(totals.ni)}{fmtNum(totals.net)} -
-
- )} -
- - {showAdd && ( - setShowAdd(false)} - onSubmit={data => createMut.mutate(data)} - isLoading={createMut.isPending} - /> - )} - - {editing && ( - setEditing(null)} - onSubmit={data => updateMut.mutate({ id: editing.id, data })} - isLoading={updateMut.isPending} - /> - )} - - {showP60 && ( - setShowP60(false)} - onSubmit={data => p60Mut.mutate(data)} - isLoading={p60Mut.isPending} - /> - )} - - ); -} diff --git a/frontend/src/pages/tax/RateConfigModal.tsx b/frontend/src/pages/tax/RateConfigModal.tsx deleted file mode 100644 index 31bc30a..0000000 --- a/frontend/src/pages/tax/RateConfigModal.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import { useEffect, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { X, Plus, Trash2, Loader2 } from "lucide-react"; -import { - TAX_QUERY_KEYS, - getRateConfig, - upsertRateConfig, - type TaxRateConfigUpdate, -} from "@/api/tax"; -import { taxYearDisplay } from "./TaxYearSelector"; - -// --------------------------------------------------------------------------- -// Types for local form state — all numeric values kept as strings for inputs -// --------------------------------------------------------------------------- - -type Tab = "income_tax" | "ni" | "cgt" | "dividend"; - -type BandRow = { from: string; to: string; rate: string }; - -type CgtForm = { exempt: string; basic_rate: string; higher_rate: string }; - -type DivForm = { - allowance: string; - basic_rate: string; - higher_rate: string; - additional_rate: string; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function pctIn(d: number) { - // Convert stored decimal (0.20) → display percentage string ("20") - const p = d * 100; - return p % 1 === 0 ? String(p) : p.toFixed(4).replace(/\.?0+$/, ""); -} - -function parseBands(rows: BandRow[]) { - return rows.map(r => ({ - from: parseFloat(r.from) || 0, - to: r.to.trim() === "" ? null : parseFloat(r.to), - rate: (parseFloat(r.rate) || 0) / 100, - })); -} - -// --------------------------------------------------------------------------- -// Band table sub-component -// --------------------------------------------------------------------------- - -function BandTable({ - bands, - onChange, -}: { - bands: BandRow[]; - onChange: (rows: BandRow[]) => void; -}) { - const inp = - "w-full rounded border border-input bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring tabular-nums"; - - function update(i: number, field: keyof BandRow, val: string) { - const next = bands.map((b, idx) => (idx === i ? { ...b, [field]: val } : b)); - onChange(next); - } - - function addRow() { - onChange([...bands, { from: "", to: "", rate: "" }]); - } - - function removeRow(i: number) { - onChange(bands.filter((_, idx) => idx !== i)); - } - - return ( -
-
- - - - - - - - - - {bands.map((b, i) => ( - - - - - - - ))} - -
From (£)To (£, blank = unlimited)Rate (%) -
- update(i, "from", e.target.value)} - className={inp} - placeholder="0" - /> - - update(i, "to", e.target.value)} - className={inp} - placeholder="—" - /> - - update(i, "rate", e.target.value)} - className={inp} - placeholder="0" - /> - - -
-
- -
- ); -} - -// --------------------------------------------------------------------------- -// Main modal -// --------------------------------------------------------------------------- - -const TABS: { key: Tab; label: string }[] = [ - { key: "income_tax", label: "Income Tax" }, - { key: "ni", label: "NI" }, - { key: "cgt", label: "CGT" }, - { key: "dividend", label: "Dividends" }, -]; - -interface Props { - taxYear: number; - onClose: () => void; -} - -export default function RateConfigModal({ taxYear, onClose }: Props) { - const qc = useQueryClient(); - const [tab, setTab] = useState("income_tax"); - - const [itBands, setItBands] = useState([]); - const [niBands, setNiBands] = useState([]); - const [cgt, setCgt] = useState({ exempt: "", basic_rate: "", higher_rate: "" }); - const [div, setDiv] = useState({ - allowance: "", - basic_rate: "", - higher_rate: "", - additional_rate: "", - }); - const [error, setError] = useState(null); - - const { data: config, isLoading } = useQuery({ - queryKey: TAX_QUERY_KEYS.rateConfig(taxYear), - queryFn: () => getRateConfig(taxYear), - }); - - // Populate form state once config loads - useEffect(() => { - if (!config) return; - const r = config.rates; - - if (r.income_tax?.bands) { - setItBands( - r.income_tax.bands.map(b => ({ - from: String(b.from), - to: b.to === null ? "" : String(b.to), - rate: pctIn(b.rate), - })) - ); - } - - if (r.ni?.bands) { - setNiBands( - r.ni.bands.map(b => ({ - from: String(b.from), - to: b.to === null ? "" : String(b.to), - rate: pctIn(b.rate), - })) - ); - } - - if (r.cgt) { - setCgt({ - exempt: String(r.cgt.exempt), - basic_rate: pctIn(r.cgt.basic_rate), - higher_rate: pctIn(r.cgt.higher_rate), - }); - } - - if (r.dividend) { - setDiv({ - allowance: String(r.dividend.allowance), - basic_rate: pctIn(r.dividend.basic_rate), - higher_rate: pctIn(r.dividend.higher_rate), - additional_rate: pctIn(r.dividend.additional_rate), - }); - } - }, [config]); - - const saveMut = useMutation({ - mutationFn: (data: TaxRateConfigUpdate) => upsertRateConfig(taxYear, data), - onSuccess: () => { - qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.rateConfig(taxYear) }); - qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) }); - onClose(); - }, - onError: (err: unknown) => { - setError((err as Error)?.message ?? "Failed to save"); - }, - }); - - function handleSave() { - setError(null); - const payload: TaxRateConfigUpdate = { - income_tax: itBands.length > 0 ? { bands: parseBands(itBands) } : undefined, - ni: niBands.length > 0 ? { bands: parseBands(niBands) } : undefined, - cgt: { - exempt: parseFloat(cgt.exempt) || 0, - basic_rate: (parseFloat(cgt.basic_rate) || 0) / 100, - higher_rate: (parseFloat(cgt.higher_rate) || 0) / 100, - }, - dividend: { - allowance: parseFloat(div.allowance) || 0, - basic_rate: (parseFloat(div.basic_rate) || 0) / 100, - higher_rate: (parseFloat(div.higher_rate) || 0) / 100, - additional_rate: (parseFloat(div.additional_rate) || 0) / 100, - }, - }; - saveMut.mutate(payload); - } - - const inp = - "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; - - return ( -
-
- {/* Header */} -
-

- Rate Configuration — {taxYearDisplay(taxYear)} -

- -
- - {/* Tabs */} -
- {TABS.map(t => ( - - ))} -
- - {/* Body */} -
- {isLoading ? ( -
- - Loading… -
- ) : ( - <> - {tab === "income_tax" && ( -
-

- Bands are applied to gross income. The first band (0%) covers the personal - allowance; its upper limit is overridden by the tax code. -

- -
- )} - - {tab === "ni" && ( -
-

- Primary Class 1 NI bands. Rates are applied to gross earnings. -

- -
- )} - - {tab === "cgt" && ( -
-
- - setCgt(c => ({ ...c, exempt: e.target.value }))} - className={inp} - placeholder="3000" - /> -
-
-
- - setCgt(c => ({ ...c, basic_rate: e.target.value }))} - className={inp} - placeholder="18" - /> -
-
- - setCgt(c => ({ ...c, higher_rate: e.target.value }))} - className={inp} - placeholder="24" - /> -
-
-

- Basic rate applies when remaining basic-rate band > 0; higher rate applies to gains above. -

-
- )} - - {tab === "dividend" && ( -
-
- - setDiv(d => ({ ...d, allowance: e.target.value }))} - className={inp} - placeholder="500" - /> -
-
-
- - setDiv(d => ({ ...d, basic_rate: e.target.value }))} - className={inp} - placeholder="8.75" - /> -
-
- - setDiv(d => ({ ...d, higher_rate: e.target.value }))} - className={inp} - placeholder="33.75" - /> -
-
- - setDiv(d => ({ ...d, additional_rate: e.target.value }))} - className={inp} - placeholder="39.35" - /> -
-
-
- )} - - )} -
- - {/* Footer */} - {error && ( -

- {error} -

- )} -
- - -
-
-
- ); -} diff --git a/frontend/src/pages/tax/TaxNISummaryCard.tsx b/frontend/src/pages/tax/TaxNISummaryCard.tsx deleted file mode 100644 index 2121aec..0000000 --- a/frontend/src/pages/tax/TaxNISummaryCard.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { type TaxReport, type BandBreakdown } from "@/api/tax"; - -function gbp(v: string) { - return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); -} - -function pct(r: number) { - return `${(r * 100 % 1 === 0 ? (r * 100).toFixed(0) : (r * 100).toFixed(1))}%`; -} - -function OwedRow({ owed, overpaid }: { owed: string; overpaid?: boolean }) { - const n = Number(owed); - const isZero = n === 0; - const color = isZero - ? "text-muted-foreground" - : overpaid !== undefined - ? overpaid ? "text-green-500" : "text-yellow-500" - : n < 0 ? "text-green-500" : "text-yellow-500"; - - return ( -
- - {n < 0 || (n >= 0 && !isZero && overpaid) ? "Overpaid" : n === 0 ? "Balanced" : "Still owed"} - - {gbp(owed)} -
- ); -} - -function Kv({ label, value, bold }: { label: string; value: string; bold?: boolean }) { - return ( -
- {label} - {value} -
- ); -} - -function BandTable({ bands }: { bands: BandBreakdown[] }) { - if (bands.length === 0) return

No taxable amount in this category.

; - return ( - - - - - - - - - - {bands.map((b, i) => ( - - - - - - ))} - -
RateTaxableTax
{pct(b.rate)}{gbp(String(b.taxable))}{gbp(String(b.tax))}
- ); -} - -interface Props { - report: TaxReport; -} - -export default function TaxNISummaryCard({ report }: Props) { - const it = report.income_tax; - const ni = report.ni; - - return ( -
-

Income Tax & National Insurance

- -
- {/* Income Tax */} -
-

Income Tax

- - - - - - - -
- - {/* NI */} -
-

National Insurance

- - - - -
-
-
- ); -} diff --git a/frontend/src/pages/tax/TaxPage.tsx b/frontend/src/pages/tax/TaxPage.tsx deleted file mode 100644 index 68b6599..0000000 --- a/frontend/src/pages/tax/TaxPage.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { useEffect, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { AlertTriangle, Loader2, SlidersHorizontal } from "lucide-react"; - -import { TAX_QUERY_KEYS, getConfiguredYears, getTaxReport, type TaxReport } from "@/api/tax"; -import TaxYearSelector, { currentTaxYear, taxYearDisplay } from "./TaxYearSelector"; -import TaxProfileCard from "./TaxProfileCard"; -import PayslipTable from "./PayslipTable"; -import TaxNISummaryCard from "./TaxNISummaryCard"; -import CGTSection from "./CGTSection"; -import DividendSection from "./DividendSection"; -import OverallLiabilityCard from "./OverallLiabilityCard"; -import RateConfigModal from "./RateConfigModal"; - -// --------------------------------------------------------------------------- -// Loading skeleton -// --------------------------------------------------------------------------- - -function SkeletonBlock({ h = "h-6", w = "w-full" }: { h?: string; w?: string }) { - return
; -} - -function ReportSkeleton() { - return ( -
- {/* at-a-glance */} -
- -
- {Array.from({ length: 8 }).map((_, i) => ( -
- - -
- ))} -
-
- {/* profile + payslips */} -
- -
- {Array.from({ length: 3 }).map((_, i) => )} -
-
-
- - -
- {/* income tax + NI */} -
- -
-
{Array.from({ length: 5 }).map((_, i) => )}
-
{Array.from({ length: 4 }).map((_, i) => )}
-
-
- {/* CGT + dividends */} - {[48, 36].map(h => ( -
- - -
- ))} - {/* overall */} -
- -
- - - -
-
-
- ); -} - -// --------------------------------------------------------------------------- -// Quick-summary card — shows top-level numbers from report while full UI is built -// --------------------------------------------------------------------------- - -function fmt(v: string) { - return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v)); -} - -function SummaryOverview({ report }: { report: TaxReport }) { - const items = [ - { label: "Gross income", value: fmt(report.income.gross_income) }, - { label: "Income tax", value: fmt(report.income_tax.liability) }, - { label: "National Insurance", value: fmt(report.ni.liability) }, - { label: "CGT", value: fmt(report.cgt.liability) }, - { label: "Dividend tax", value: fmt(report.dividends.liability) }, - { label: "Total liability", value: fmt(report.summary.total_liability), bold: true }, - { label: "Already withheld", value: fmt(report.summary.total_withheld) }, - { - label: report.summary.overpaid ? "Overpaid" : "Still owed", - value: fmt(report.summary.net_owed), - highlight: true, - overpaid: report.summary.overpaid, - }, - ]; - - return ( -
-

- {taxYearDisplay(report.tax_year)} at a glance -

-
- {items.map(({ label, value, bold, highlight, overpaid }) => ( -
-
{label}
-
- {value} -
-
- ))} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Main page -// --------------------------------------------------------------------------- - -export default function TaxPage() { - const { data: years = [], isLoading: yearsLoading } = useQuery({ - queryKey: TAX_QUERY_KEYS.configuredYears, - queryFn: getConfiguredYears, - }); - - const [taxYear, setTaxYear] = useState(null); - const [showRates, setShowRates] = useState(false); - - // Once years load, pick the best default: current tax year if available, - // otherwise the highest configured year. - useEffect(() => { - if (years.length === 0) return; - const preferred = currentTaxYear(); - setTaxYear(years.includes(preferred) ? preferred : Math.max(...years)); - }, [years]); - - const { - data: report, - isLoading: reportLoading, - isError, - error, - } = useQuery({ - queryKey: TAX_QUERY_KEYS.report(taxYear ?? 0), - queryFn: () => getTaxReport(taxYear!), - enabled: taxYear !== null, - }); - - if (yearsLoading || taxYear === null) { - return ( -
- - Loading tax data… -
- ); - } - - if (years.length === 0) { - return ( -
-
-

No tax rate configuration found.

-

- Run the database migration to seed default rates. -

-
-
- ); - } - - return ( -
- {/* Header */} -
-

Tax

-
- - -
-
- - {/* Disclaimer */} -
- - Estimates only — not financial or tax advice. Always verify against HMRC. -
- - {/* Report area */} - {reportLoading && } - - {isError && ( -
- {(error as Error)?.message ?? "Failed to load tax report."} -
- )} - - {report && ( - <> - - - - - - - - - )} - - {showRates && taxYear && ( - setShowRates(false)} /> - )} -
- ); -} diff --git a/frontend/src/pages/tax/TaxProfileCard.tsx b/frontend/src/pages/tax/TaxProfileCard.tsx deleted file mode 100644 index 7fde7f7..0000000 --- a/frontend/src/pages/tax/TaxProfileCard.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Pencil, Check, X, Loader2 } from "lucide-react"; -import { - TAX_QUERY_KEYS, - getTaxProfile, - upsertTaxProfile, - type TaxProfileCreate, -} from "@/api/tax"; - -interface Props { - taxYear: number; -} - -export default function TaxProfileCard({ taxYear }: Props) { - const qc = useQueryClient(); - - const { data: profile, isLoading, isError, error } = useQuery({ - queryKey: TAX_QUERY_KEYS.profile(taxYear), - queryFn: () => getTaxProfile(taxYear), - retry: (_, err: unknown) => { - const status = (err as { response?: { status?: number } })?.response?.status; - return status !== 404; - }, - }); - - const upsertMut = useMutation({ - mutationFn: (data: TaxProfileCreate) => upsertTaxProfile(taxYear, data), - onSuccess: () => { - qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.profile(taxYear) }); - qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) }); - setEditing(false); - }, - }); - - const [editing, setEditing] = useState(false); - const [form, setForm] = useState({ tax_code: "", employer_name: "", is_cumulative: true }); - - function openEdit() { - setForm({ - tax_code: profile?.tax_code ?? "1257L", - employer_name: profile?.employer_name ?? "", - is_cumulative: profile?.is_cumulative ?? true, - }); - setEditing(true); - } - - function handleSave() { - upsertMut.mutate({ - tax_code: form.tax_code.trim() || "1257L", - employer_name: form.employer_name.trim() || null, - is_cumulative: form.is_cumulative, - }); - } - - const inp = "rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"; - const is404 = (error as { response?: { status?: number } })?.response?.status === 404; - - return ( -
-
-

Tax Profile

- {!editing && ( - - )} -
- - {isLoading && ( -
- - Loading… -
- )} - - {!isLoading && !editing && ( - <> - {isError && !is404 && ( -

Failed to load profile.

- )} - {(is404 || !profile) && ( -

- No profile for this tax year.{" "} - {" "} - to improve estimate accuracy. -

- )} - {profile && ( -
-
-
Tax Code
-
{profile.tax_code}
-
-
-
Employer
-
{profile.employer_name ?? "—"}
-
-
-
Mode
-
{profile.is_cumulative ? "Cumulative" : "Week 1 / Month 1"}
-
-
- )} - - )} - - {editing && ( -
-
-
- - setForm(f => ({ ...f, tax_code: e.target.value }))} - className={`${inp} w-full font-mono`} - placeholder="1257L" - /> -
-
- - setForm(f => ({ ...f, employer_name: e.target.value }))} - className={`${inp} w-full`} - placeholder="Optional" - /> -
-
- - - -
- - -
- - {upsertMut.isError && ( -

{(upsertMut.error as Error)?.message ?? "Failed to save"}

- )} -
- )} -
- ); -} diff --git a/frontend/src/pages/tax/TaxYearSelector.tsx b/frontend/src/pages/tax/TaxYearSelector.tsx deleted file mode 100644 index 66f18cb..0000000 --- a/frontend/src/pages/tax/TaxYearSelector.tsx +++ /dev/null @@ -1,31 +0,0 @@ -interface Props { - years: number[]; - value: number; - onChange: (year: number) => void; -} - -export function taxYearDisplay(year: number) { - return `${year - 1}/${String(year).slice(2)}`; -} - -export function currentTaxYear(): number { - const now = new Date(); - const y = now.getFullYear(); - return now >= new Date(y, 3, 6) ? y + 1 : y; -} - -export default function TaxYearSelector({ years, value, onChange }: Props) { - return ( - - ); -} diff --git a/frontend/src/pages/transactions/TransactionDetailDrawer.tsx b/frontend/src/pages/transactions/TransactionDetailDrawer.tsx index b25c6f6..01e7f75 100644 --- a/frontend/src/pages/transactions/TransactionDetailDrawer.tsx +++ b/frontend/src/pages/transactions/TransactionDetailDrawer.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; import { X, Paperclip, Upload, Trash2, FileText, ImageIcon, Loader2, - ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Sparkles, CheckCircle, Repeat, + ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Sparkles, CheckCircle, } from "lucide-react"; import { cn } from "@/utils/cn"; import { formatCurrency } from "@/utils/currency"; @@ -59,7 +59,6 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate const [parseResult, setParseResult] = useState<{ attId: string; data: ParsedReceipt } | null>(null); const [parseError, setParseError] = useState(null); const [applySuccess, setApplySuccess] = useState(false); - const [isRecurring, setIsRecurring] = useState(transaction.is_recurring); const fileInputRef = useRef(null); const Icon = TYPE_ICONS[transaction.type] ?? ArrowDownCircle; @@ -111,18 +110,6 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate }, }); - const recurringMutation = useMutation({ - mutationFn: (value: boolean) => updateTransaction(transaction.id, { - is_recurring: value, - recurring_rule: { manually_set: true }, - }), - onSuccess: (_data, value) => { - setIsRecurring(value); - qc.invalidateQueries({ queryKey: ["transactions"] }); - qc.invalidateQueries({ queryKey: ["subscriptions"] }); - }, - }); - const handleFiles = useCallback((files: FileList | null) => { if (!files) return; setUploadError(null); @@ -209,32 +196,6 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate )}
- {/* Recurring toggle */} -
-
- -
-

Recurring payment

-

- {isRecurring ? "Appears in Subscriptions" : "Not marked as recurring"} -

-
-
- -
- {/* Attachments */}
diff --git a/frontend/src/pages/transactions/TransactionFormModal.tsx b/frontend/src/pages/transactions/TransactionFormModal.tsx index 7382318..14b6700 100644 --- a/frontend/src/pages/transactions/TransactionFormModal.tsx +++ b/frontend/src/pages/transactions/TransactionFormModal.tsx @@ -2,7 +2,7 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { format } from "date-fns"; -import { X, Loader2, Sparkles, RotateCcw } from "lucide-react"; +import { X, Loader2, Sparkles } from "lucide-react"; import type { Account } from "@/api/accounts"; const schema = z.object({ @@ -40,11 +40,9 @@ interface Props { initialValues?: TransactionInitialValues; parsedFromReceipt?: boolean; showAiDebug?: boolean; - onRescan?: () => void; - rescanLoading?: boolean; } -export default function TransactionFormModal({ accounts, categories, onClose, onSubmit, isLoading, initialValues, parsedFromReceipt, showAiDebug, onRescan, rescanLoading }: Props) { +export default function TransactionFormModal({ accounts, categories, onClose, onSubmit, isLoading, initialValues, parsedFromReceipt, showAiDebug }: Props) { const { register, handleSubmit, watch, formState: { errors } } = useForm
({ resolver: zodResolver(schema), defaultValues: { @@ -91,40 +89,14 @@ export default function TransactionFormModal({ accounts, categories, onClose, on {parsedFromReceipt && !showAiDebug && (
- Fields pre-filled from receipt — review before saving - {onRescan && ( - - )} + Fields pre-filled from receipt — review before saving
)} {parsedFromReceipt && showAiDebug && (
-
-

- AI scan result — review before saving -

- {onRescan && ( - - )} -
+

+ AI scan result — review before saving +

Merchant{initialValues?.merchant ?? not detected} Amount{initialValues?.amount != null ? initialValues.amount : not detected} diff --git a/frontend/src/pages/transactions/TransactionList.tsx b/frontend/src/pages/transactions/TransactionList.tsx index 99542a3..f2d044b 100644 --- a/frontend/src/pages/transactions/TransactionList.tsx +++ b/frontend/src/pages/transactions/TransactionList.tsx @@ -10,7 +10,7 @@ import { cn } from "@/utils/cn"; import { format, startOfMonth, subMonths, startOfYear } from "date-fns"; import { Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload, - ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw, ScanLine, Loader2, Repeat, + ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw, ScanLine, Loader2, } from "lucide-react"; import TransactionFormModal from "./TransactionFormModal"; import TransactionDetailDrawer from "./TransactionDetailDrawer"; @@ -55,8 +55,6 @@ export default function TransactionList() { const receiptFileRef = useRef(null); const [scanError, setScanError] = useState(null); const [scanning, setScanning] = useState(false); - const [rescanLoading, setRescanLoading] = useState(false); - const [rescanKey, setRescanKey] = useState(0); const receiptInputRef = useRef(null); const [search, setSearch] = useState(""); const [filterType, setFilterType] = useState(""); @@ -115,27 +113,6 @@ export default function TransactionList() { } } - async function handleRescan() { - if (!receiptFileRef.current) return; - setRescanLoading(true); - setScanError(null); - try { - const parsed = await parseReceiptFile(receiptFileRef.current); - setReceiptParsed(parsed); - setRescanKey((k) => k + 1); - } catch (e: any) { - const detail = e?.response?.data?.detail; - const status = e?.response?.status; - if (detail) { - setScanError(typeof detail === "string" ? detail : `HTTP ${status}: ${JSON.stringify(detail)}`); - } else { - setScanError(`Rescan failed — ${e?.message ?? "unknown error"}`); - } - } finally { - setRescanLoading(false); - } - } - async function handleReceiptFile(file: File) { setScanning(true); setScanError(null); @@ -326,11 +303,6 @@ export default function TransactionList() {

{txn.description}

- {txn.is_recurring && ( - - - - )} {txn.attachment_refs?.length > 0 && ( )} @@ -396,7 +368,6 @@ export default function TransactionList() { {showForm && ( { setShowForm(false); setReceiptParsed(null); receiptFileRef.current = null; }} @@ -413,8 +384,6 @@ export default function TransactionList() { } : undefined} parsedFromReceipt={!!receiptParsed} showAiDebug={aiSettings?.debug ?? false} - onRescan={handleRescan} - rescanLoading={rescanLoading} /> )} diff --git a/frontend/src/utils/cssVar.ts b/frontend/src/utils/cssVar.ts deleted file mode 100644 index f3bcf8b..0000000 --- a/frontend/src/utils/cssVar.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Reads a CSS custom property from the root element and returns a valid colour - * string suitable for Plotly (which does not resolve CSS var() references). - * - * The project stores HSL components without hsl() — e.g. "--primary: 252 87% 67%". - * This converts them to "hsl(252, 87%, 67%)" or "hsla(..., alpha)" for Plotly. - */ -export function cssVar(name: string, alpha?: number): string { - const raw = getComputedStyle(document.documentElement) - .getPropertyValue(name) - .trim(); - - const parts = raw.split(/\s+/); - if (parts.length === 3) { - return alpha !== undefined - ? `hsla(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})` - : `hsl(${parts[0]}, ${parts[1]}, ${parts[2]})`; - } - - return raw; -} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index a729993..71ae8ea 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -36,7 +36,7 @@ const config: Config = { foreground: "hsl(var(--card-foreground))", }, success: { DEFAULT: "hsl(var(--success, 142 71% 45%))" }, - warning: { DEFAULT: "hsl(var(--warning, 38 92% 58%))" }, + warning: { DEFAULT: "#f59e0b" }, }, borderRadius: { lg: "var(--radius)",