Compare commits

..

No commits in common. "afb5e99bb2c5912471da24ef7f5afecfa5d95402" and "6111424f472534da7223da5f44cf4fe0be9c94e8" have entirely different histories.

57 changed files with 128 additions and 6416 deletions

View file

@ -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 - 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 - Full transaction history with categories, tags, merchant tracking, and notes
- Transfer detection between accounts - 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) - 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 - **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 - 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 - 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 - 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 ### Reports
- 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
### Categories ### Categories
- 45 built-in system categories across income, expense, and transfer types - 45 built-in system categories across income, expense, and transfer types
- Create custom categories with a name, type, and colour - Create custom categories with a name, type, and colour
- Rename and recolour existing custom categories - Rename and recolour existing custom categories
- Managed in **Settings → 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 ### Reports
Seven report views: Seven report views:
1. Net Worth over time (area chart with time slider) 1. Net Worth over time (area chart with time slider)

View file

@ -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 | 68 | ±2 days |
| Fortnightly | 1315 | ±3 days |
| Monthly | 2635 | ±5 days |
| Quarterly | 8595 | ±10 days |
| Yearly | 355375 | ±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 (AZ)
#### 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<SubscriptionsSummary> => ...
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`).

View file

@ -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 | 112; `NULL` if `is_p60 = true` |
| `period_year` | SMALLINT | Calendar year of the payslip |
| `gross_pay` | NUMERIC(14,2) | |
| `income_tax_withheld` | NUMERIC(14,2) | |
| `ni_withheld` | NUMERIC(14,2) | |
| `net_pay` | NUMERIC(14,2) | |
| `is_p60` | BOOLEAN | `true` = this is the annual P60 figure |
| `notes_enc` | BYTEA | AES-256-GCM encrypted, optional |
| `created_at` | TIMESTAMPTZ | |
When a P60 is entered for a tax year, all existing individual payslips for that profile are deleted and replaced by the single P60 row (confirmed via a warning dialog in the UI).
### Table: `manual_cgt_disposals`
For assets not tracked in the investments section (e.g. property, share schemes, other).
| Column | Type | Notes |
|---|---|---|
| `id` | UUID PK | |
| `user_id` | UUID FK → users | RLS keyed |
| `tax_year` | INTEGER | |
| `disposal_date` | DATE | |
| `asset_description_enc` | BYTEA | AES-256-GCM encrypted |
| `proceeds` | NUMERIC(14,2) | |
| `cost_basis` | NUMERIC(14,2) | |
| `notes_enc` | BYTEA | AES-256-GCM encrypted |
| `created_at` | TIMESTAMPTZ | |
`gain_loss` is **not stored** — computed in the service as `proceeds cost_basis`.
---
## Tax Calculation Engine
### File: `backend/app/services/tax_service.py`
Rates are loaded from `tax_rate_configs` (DB), not hardcoded. The service caches the loaded config per `(user_id, tax_year)` for the lifetime of the request.
#### Tax year helper
UK tax year runs 6 April → 5 April. Convention: `tax_year=2025` means the year **ending** 5 April 2025 (the 2024/25 tax year).
```python
def tax_year_for_date(d: date) -> int:
"""Return the tax_year int for a given date. tax_year=N means 6 Apr (N-1) → 5 Apr N."""
if (d.month, d.day) >= (4, 6):
return d.year + 1
return d.year
```
#### Core functions
All calculation functions receive a `rates: dict` argument (the loaded config for that year/type) rather than reading from constants.
```python
async def load_rates(db, user_id, tax_year) -> dict:
# Returns {"income_tax": {...}, "ni": {...}, "cgt": {...}, "dividend": {...}}
# Loads from tax_rate_configs table; raises 404 if year not configured
def parse_tax_code(code: str) -> dict:
# Returns: {"allowance": int, "rate_override": float | None, "k_code": bool}
def calculate_income_tax(gross_income: Decimal, tax_code: str, rates: dict) -> dict:
# Returns: {"personal_allowance": Decimal, "taxable_income": Decimal,
# "liability": Decimal, "band_breakdown": [...]}
def calculate_ni(gross_income: Decimal, rates: dict) -> dict:
# Returns: {"liability": Decimal, "band_breakdown": [...]}
def calculate_cgt(gains: Decimal, gross_income: Decimal, rates: dict) -> dict:
# Determines basic vs higher rate from remaining basic rate band
# Returns: {"gross_gain": Decimal, "exempt": Decimal, "taxable_gain": Decimal,
# "rate_applied": float, "liability": Decimal}
def calculate_dividend_tax(dividends: Decimal, gross_income: Decimal, rates: dict) -> dict:
# Returns: {"gross_dividends": Decimal, "allowance": Decimal,
# "taxable_dividends": Decimal, "liability": Decimal, "rate_applied": float}
async def build_tax_report(db, user_id, tax_year) -> dict:
# Loads rates, pulls payslip totals, investment disposals, dividend transactions
# Returns the full report payload
```
#### Tax code parser
| Code pattern | Behaviour |
|---|---|
| `1257L`, `1257M`, `1257N` | allowance = digits × 10 |
| `BR` | allowance = 0, flat 20% on all income |
| `D0` | flat 40% on all income |
| `D1` | flat 45% on all income |
| `NT` | no tax |
| `K100` | negative allowance: taxable income += digits × 10 |
| `0T` | allowance = 0, standard bands apply |
| `W1`/`M1` suffix | non-cumulative (informational only for Phase 1) |
Personal allowance taper: reduce by £1 for every £2 of income above £100,000, down to zero at £125,140.
---
## Backend API
### File: `backend/app/api/v1/tax.py`
All routes prefixed `/tax`.
| Method | Path | Description |
|---|---|---|
| GET | `/tax/rate-configs` | List configured tax years for this user |
| GET | `/tax/rate-configs/{tax_year}` | Get full rate config for a year |
| PUT | `/tax/rate-configs/{tax_year}` | Create or update rate config for a year |
| GET | `/tax/profile/{tax_year}` | Get profile for a tax year (404 if none) |
| PUT | `/tax/profile/{tax_year}` | Create or update profile (tax code, employer name) |
| GET | `/tax/payslips/{tax_year}` | List payslips for a tax year |
| POST | `/tax/payslips/{tax_year}` | Add a payslip |
| PUT | `/tax/payslips/{id}` | Edit a payslip |
| DELETE | `/tax/payslips/{id}` | Delete a payslip |
| POST | `/tax/payslips/{tax_year}/p60` | Enter P60 — replaces all individual payslips |
| GET | `/tax/cgt-disposals/{tax_year}` | List manual CGT disposals |
| POST | `/tax/cgt-disposals/{tax_year}` | Add a manual disposal |
| PUT | `/tax/cgt-disposals/{id}` | Edit |
| DELETE | `/tax/cgt-disposals/{id}` | Delete |
| GET | `/tax/report/{tax_year}` | Full computed tax report for a year |
### Pydantic schemas: `backend/app/schemas/tax.py`
- `TaxRateConfigUpdate` / `TaxRateConfigResponse`
- `TaxProfileCreate` / `TaxProfileResponse`
- `PayslipCreate` / `PayslipResponse`
- `P60Entry` (gross_pay, income_tax_withheld, ni_withheld, net_pay)
- `ManualDisposalCreate` / `ManualDisposalResponse`
- `TaxReportResponse`
---
## Migration
New Alembic migration: `add_tax_tables`
- Creates `tax_rate_configs`, `tax_profiles`, `payslips`, `manual_cgt_disposals`
- Adds RLS policies (same pattern as other tables: `app.current_user_id`)
- Encrypted columns stored as `_enc bytea`: `employer_name_enc`, `notes_enc`, `asset_description_enc`
- Seeds `tax_rate_configs` rows for 2025 and 2026 for all existing users inside the migration (so existing accounts get rates without re-registering)
---
## Frontend
### Nav changes
Add `{ href: "/tax", icon: Receipt, label: "Tax" }` to both:
- `frontend/src/components/layout/Sidebar.tsx` — between Reports and Predictions
- `frontend/src/components/layout/MobileNav.tsx` — same position
- `frontend/src/App.tsx` — add `/tax` route pointing to `TaxPage`
### API client: `frontend/src/api/tax.ts`
Typed functions for all endpoints. Interfaces for:
- `TaxRateConfig`, `TaxRateConfigUpdate`
- `TaxProfile`, `TaxProfileCreate`
- `Payslip`, `PayslipCreate`, `P60Entry`
- `ManualDisposal`, `ManualDisposalCreate`
- `TaxReport`
### Page: `frontend/src/pages/tax/TaxPage.tsx`
Top-level page at `/tax`. Contains the full tax UI.
#### Layout
```
[ Tax Year selector: 2024/25 | 2025/26 | ... ] [ Rate Config button (edit rates for year) ]
[ Tax Profile card ]
Employer: Acme Ltd Tax Code: 1257L [ Edit ]
[ Income & PAYE section ]
Payslip table (month | gross | tax withheld | NI withheld | net)
[ + Add Payslip ] [ Enter P60 ]
Summary row: totals
[ Tax & NI Summary card ]
Gross income £xx,xxx
Personal allowance £12,570
Taxable income £xx,xxx
─────────────────────────────────────────
Income tax liability £x,xxx Withheld £x,xxx [ Owed / Overpaid ]
NI liability £x,xxx Withheld £x,xxx [ Owed / Overpaid ]
[ Capital Gains section ]
Auto-detected disposals from investments (read-only table)
Manual disposals table [ + Add Disposal ]
Summary: total gains | exempt | taxable | estimated CGT
[ Dividends section ]
Auto-detected from investment dividend transactions (read-only)
Summary: total dividends | allowance | taxable | estimated dividend tax
[ Overall Liability card ]
┌──────────────────────────────────────────┐
│ Total liability £x,xxx │
│ Total withheld £x,xxx │
│ ─────────────────────────────────────── │
│ Net owed to HMRC / Overpaid £x,xxx │
└──────────────────────────────────────────┘
[ Disclaimer: estimates only — not financial advice ]
```
#### Components to build (all in `frontend/src/pages/tax/`)
- `TaxPage.tsx` — top-level, holds selected tax year state
- `TaxYearSelector.tsx` — dropdown of configured years
- `RateConfigModal.tsx` — shows/edits the JSONB rate bands for the selected year (table of bands, editable inputs)
- `TaxProfileCard.tsx` — shows/edits tax code and employer name
- `PayslipTable.tsx` — list, add, edit, delete; "Enter P60" button with confirmation dialog
- `PayslipFormModal.tsx` — single payslip month form
- `P60Modal.tsx` — four-field form (gross, tax withheld, NI withheld, net) with warning dialog
- `TaxNISummaryCard.tsx` — computed liability vs withheld, owed/overpaid highlighted
- `CGTSection.tsx` — auto + manual disposal tables, summary
- `ManualDisposalFormModal.tsx`
- `DividendSection.tsx` — auto-pulled, summary only
- `OverallLiabilityCard.tsx` — final totals
---
## Implementation Order
1. **Migration** — create the four tables with RLS policies; seed 2025/2026 rate configs for existing users
2. **Tax calculation engine** (`tax_service.py`) — pure functions taking `rates: dict`, unit-testable without DB
3. **Backend models** (`db/models/tax.py`) — SQLAlchemy mapped classes
4. **Pydantic schemas** (`schemas/tax.py`)
5. **Service layer** — DB queries for CRUD + `load_rates()` + `build_tax_report()`
6. **API endpoints** (`api/v1/tax.py`) + register in `router.py`
7. **Frontend API client** (`api/tax.ts`)
8. **Nav wiring** — add Tax to Sidebar, MobileNav, App.tsx routes
9. **Tax page UI** — build components top-to-bottom following the layout above
10. **Rate config UI**`RateConfigModal` so rates can be edited without touching code
11. **End-to-end test** — enter a full year of payslips, verify liability matches HMRC calculator
---
## Testing Checkpoints
- Tax code parser: `1257L`, `BR`, `D0`, `K100`, `0T`, `1257M`
- Income tax: verify bands at £12,570 / £50,270 / £100,000 (taper) / £125,140
- NI: verify thresholds
- CGT: basic rate (18%) vs higher rate (24%) taxpayer
- P60 replacement: individual payslips deleted before P60 insert
- Investment disposal auto-detection: verify `tax_year_for_date` boundary (6 Apr)
- Rate config: edit a band value, confirm report recalculates using new value
---
## Notes & Decisions
- **Tax year convention**: `tax_year = 2025` = year ending 5 April 2025 = 2024/25. Always display as "2024/25" in the UI.
- **Configurable rates (Option A)**: Rates live in `tax_rate_configs` DB table. Pre-populated for 2025 and 2026. When a new Budget changes rates, the user edits them in-app via `RateConfigModal` — no code change or rebuild needed. Adding a brand new tax year requires a small migration to insert new rows, but that's still no code change in the calculation logic.
- **Encrypted fields**: `employer_name_enc`, `notes_enc`, `asset_description_enc` — all PII stored as `_enc bytea` using `encrypt_field`/`decrypt_field` from `core/security.py`.
- **`gain_loss` not stored**: Computed in service as `proceeds cost_basis`. Not a DB column.
- **CGT rates (post Oct 2024 Budget)**: 18% basic rate, 24% higher rate. For 2024/25 (tax_year=2025), the change was effective 30 Oct 2024 mid-year — the seeded rate uses 18%/24% for the full year with a disclaimer note in the UI.
- **CGT rate determination**: Requires knowing whether the user is basic or higher rate (remaining basic rate band after income). `build_tax_report()` computes income tax first, then passes the remaining band to `calculate_cgt()`.
- **Sidebar placement**: Tax sits between Reports and Predictions in both `Sidebar.tsx` and `MobileNav.tsx`.
- **Disclaimer**: Report UI must include a visible note that figures are estimates for informational purposes only and are not financial or tax advice.
- **Future expansion**: `tax_profiles` unique constraint is `(user_id, tax_year)` for now — relax to allow multiple rows per year when multi-employment is added.

View file

@ -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")

View file

@ -1,6 +1,6 @@
from fastapi import APIRouter 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 = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["auth"]) 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(predictions.router)
router.include_router(admin.router) router.include_router(admin.router)
router.include_router(settings.router) router.include_router(settings.router)
router.include_router(subscriptions.router)
router.include_router(tax.router)

View file

@ -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,
}

View file

@ -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))

View file

@ -611,18 +611,6 @@ async def import_transactions(
return result 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") @router.get("/import/template")
async def import_template(): async def import_template():
from fastapi.responses import Response from fastapi.responses import Response

View file

@ -11,11 +11,9 @@ from app.db.models.investment_transaction import InvestmentTransaction
from app.db.models.currency import Currency, ExchangeRate from app.db.models.currency import Currency, ExchangeRate
from app.db.models.net_worth_snapshot import NetWorthSnapshot from app.db.models.net_worth_snapshot import NetWorthSnapshot
from app.db.models.audit_log import AuditLog from app.db.models.audit_log import AuditLog
from app.db.models.tax import TaxRateConfig, TaxProfile, Payslip, ManualCGTDisposal
__all__ = [ __all__ = [
"User", "Session", "Account", "Category", "Transaction", "Budget", "User", "Session", "Account", "Category", "Transaction", "Budget",
"Asset", "AssetPrice", "InvestmentHolding", "InvestmentTransaction", "Asset", "AssetPrice", "InvestmentHolding", "InvestmentTransaction",
"Currency", "ExchangeRate", "NetWorthSnapshot", "AuditLog", "Currency", "ExchangeRate", "NetWorthSnapshot", "AuditLog",
"TaxRateConfig", "TaxProfile", "Payslip", "ManualCGTDisposal",
] ]

View file

@ -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)

View file

@ -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

View file

@ -35,8 +35,6 @@ class TransactionUpdate(BaseModel):
merchant: str | None = None merchant: str | None = None
notes: str | None = None notes: str | None = None
tags: list[str] | None = None tags: list[str] | None = None
is_recurring: bool | None = None
recurring_rule: dict | None = None
class TransactionFilter(BaseModel): class TransactionFilter(BaseModel):
@ -73,7 +71,6 @@ class TransactionResponse(BaseModel):
notes: str | None notes: str | None
tags: list[str] tags: list[str]
is_recurring: bool is_recurring: bool
recurring_rule: dict | None = None
attachment_refs: list[dict] = [] attachment_refs: list[dict] = []
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View file

@ -85,10 +85,6 @@ async def register_user(db: AsyncSession, email: str, password: str, display_nam
) )
db.add(user) db.add(user)
await db.flush() await db.flush()
from app.services.tax_service import seed_default_rates
await seed_default_rates(db, user.id)
return user return user

View file

@ -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}

View file

@ -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.01.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,
}

View file

@ -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,
},
}

View file

@ -47,7 +47,6 @@ def _to_response(t: Transaction) -> dict:
"notes": _dec(t.notes_enc), "notes": _dec(t.notes_enc),
"tags": t.tags or [], "tags": t.tags or [],
"is_recurring": t.is_recurring, "is_recurring": t.is_recurring,
"recurring_rule": t.recurring_rule,
"attachment_refs": t.attachment_refs or [], "attachment_refs": t.attachment_refs or [],
"created_at": t.created_at, "created_at": t.created_at,
"updated_at": t.updated_at, "updated_at": t.updated_at,
@ -222,10 +221,6 @@ async def update_transaction(
txn.notes_enc = _enc(data.notes) txn.notes_enc = _enc(data.notes)
if data.tags is not None: if data.tags is not None:
txn.tags = data.tags 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 txn.updated_at = now
await db.flush() await db.flush()
@ -312,7 +307,5 @@ async def import_csv(
await db.flush() await db.flush()
if imported > 0: if imported > 0:
await recalculate_balance(db, account_id) 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} return {"imported": imported, "skipped": skipped}

View file

@ -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__), ".."))

View file

@ -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")

View file

@ -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})

View file

@ -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",
},
},
];

View file

@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src" "lint": "eslint src --ext ts,tsx"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",

View file

@ -16,8 +16,6 @@ import PortfolioPage from "@/pages/investments/PortfolioPage";
import AssetDetail from "@/pages/investments/AssetDetail"; import AssetDetail from "@/pages/investments/AssetDetail";
import PredictionsPage from "@/pages/predictions/PredictionsPage"; import PredictionsPage from "@/pages/predictions/PredictionsPage";
import SettingsPage from "@/pages/settings/SettingsPage"; import SettingsPage from "@/pages/settings/SettingsPage";
import SubscriptionsPage from "@/pages/subscriptions/SubscriptionsPage";
import TaxPage from "@/pages/tax/TaxPage";
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token); const token = useAuthStore((s) => s.token);
@ -55,9 +53,7 @@ export default function App() {
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/investments" element={<PortfolioPage />} /> <Route path="/investments" element={<PortfolioPage />} />
<Route path="/investments/:assetId" element={<AssetDetail />} /> <Route path="/investments/:assetId" element={<AssetDetail />} />
<Route path="/tax" element={<TaxPage />} />
<Route path="/predictions" element={<PredictionsPage />} /> <Route path="/predictions" element={<PredictionsPage />} />
<Route path="/subscriptions" element={<SubscriptionsPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
</Routes> </Routes>
</AppShell> </AppShell>

View file

@ -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<SubscriptionsSummary> =>
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);

View file

@ -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<number[]> {
const res = await api.get("/tax/rate-configs");
return res.data;
}
export async function getRateConfig(taxYear: number): Promise<TaxRateConfig> {
const res = await api.get(`/tax/rate-configs/${taxYear}`);
return res.data;
}
export async function upsertRateConfig(
taxYear: number,
data: TaxRateConfigUpdate
): Promise<TaxRateConfig> {
const res = await api.put(`/tax/rate-configs/${taxYear}`, data);
return res.data;
}
// ---------------------------------------------------------------------------
// API functions — tax profile
// ---------------------------------------------------------------------------
export async function getTaxProfile(taxYear: number): Promise<TaxProfile> {
const res = await api.get(`/tax/profile/${taxYear}`);
return res.data;
}
export async function upsertTaxProfile(
taxYear: number,
data: TaxProfileCreate
): Promise<TaxProfile> {
const res = await api.put(`/tax/profile/${taxYear}`, data);
return res.data;
}
// ---------------------------------------------------------------------------
// API functions — payslips
// ---------------------------------------------------------------------------
export async function getPayslips(taxYear: number): Promise<Payslip[]> {
const res = await api.get(`/tax/payslips/${taxYear}`);
return res.data;
}
export async function createPayslip(
taxYear: number,
data: PayslipCreate
): Promise<Payslip> {
const res = await api.post(`/tax/payslips/${taxYear}`, data);
return res.data;
}
export async function updatePayslip(
id: string,
data: Partial<PayslipCreate>
): Promise<Payslip> {
const res = await api.put(`/tax/payslips/${id}`, data);
return res.data;
}
export async function deletePayslip(id: string): Promise<void> {
await api.delete(`/tax/payslips/${id}`);
}
export async function enterP60(taxYear: number, data: P60Entry): Promise<void> {
await api.post(`/tax/payslips/${taxYear}/p60`, data);
}
// ---------------------------------------------------------------------------
// API functions — manual CGT disposals
// ---------------------------------------------------------------------------
export async function getCgtDisposals(taxYear: number): Promise<ManualDisposal[]> {
const res = await api.get(`/tax/cgt-disposals/${taxYear}`);
return res.data;
}
export async function createCgtDisposal(
taxYear: number,
data: ManualDisposalCreate
): Promise<ManualDisposal> {
const res = await api.post(`/tax/cgt-disposals/${taxYear}`, data);
return res.data;
}
export async function updateCgtDisposal(
id: string,
data: Partial<ManualDisposalCreate>
): Promise<ManualDisposal> {
const res = await api.put(`/tax/cgt-disposals/${id}`, data);
return res.data;
}
export async function deleteCgtDisposal(id: string): Promise<void> {
await api.delete(`/tax/cgt-disposals/${id}`);
}
// ---------------------------------------------------------------------------
// API functions — report
// ---------------------------------------------------------------------------
export async function getTaxReport(taxYear: number): Promise<TaxReport> {
const res = await api.get(`/tax/report/${taxYear}`);
return res.data;
}

View file

@ -25,7 +25,6 @@ export interface Transaction {
notes: string | null; notes: string | null;
tags: string[]; tags: string[];
is_recurring: boolean; is_recurring: boolean;
recurring_rule: Record<string, unknown> | null;
attachment_refs: AttachmentRef[]; attachment_refs: AttachmentRef[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@ -44,8 +43,6 @@ export interface TransactionCreate {
merchant?: string; merchant?: string;
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
is_recurring?: boolean;
recurring_rule?: Record<string, unknown> | null;
} }
export interface TransactionPage { export interface TransactionPage {

View file

@ -2,18 +2,16 @@ import { Link, useLocation } from "react-router-dom";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { import {
LayoutDashboard, CreditCard, ArrowLeftRight, LayoutDashboard, CreditCard, ArrowLeftRight,
PiggyBank, TrendingUp, BarChart3, Sparkles, Settings, Repeat, Receipt, PiggyBank, TrendingUp, BarChart3, Sparkles, Settings,
} from "lucide-react"; } from "lucide-react";
const NAV = [ const NAV = [
{ href: "/", icon: LayoutDashboard, label: "Home" }, { href: "/", icon: LayoutDashboard, label: "Home" },
{ href: "/accounts", icon: CreditCard, label: "Accounts" }, { href: "/accounts", icon: CreditCard, label: "Accounts" },
{ href: "/transactions",icon: ArrowLeftRight, label: "Txns" }, { href: "/transactions",icon: ArrowLeftRight, label: "Txns" },
{ href: "/subscriptions",icon: Repeat, label: "Recurring" },
{ href: "/budgets", icon: PiggyBank, label: "Budgets" }, { href: "/budgets", icon: PiggyBank, label: "Budgets" },
{ href: "/investments", icon: TrendingUp, label: "Invest" }, { href: "/investments", icon: TrendingUp, label: "Invest" },
{ href: "/reports", icon: BarChart3, label: "Reports" }, { href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/tax", icon: Receipt, label: "Tax" },
{ href: "/predictions", icon: Sparkles, label: "Predict" }, { href: "/predictions", icon: Sparkles, label: "Predict" },
{ href: "/settings", icon: Settings, label: "Settings" }, { href: "/settings", icon: Settings, label: "Settings" },
]; ];

View file

@ -13,19 +13,15 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Coins, Coins,
Repeat,
Receipt,
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
{ href: "/", icon: LayoutDashboard, label: "Dashboard" }, { href: "/", icon: LayoutDashboard, label: "Dashboard" },
{ href: "/accounts", icon: CreditCard, label: "Accounts" }, { href: "/accounts", icon: CreditCard, label: "Accounts" },
{ href: "/transactions", icon: ArrowLeftRight, label: "Transactions" }, { href: "/transactions", icon: ArrowLeftRight, label: "Transactions" },
{ href: "/subscriptions", icon: Repeat, label: "Subscriptions" },
{ href: "/budgets", icon: PiggyBank, label: "Budgets" }, { href: "/budgets", icon: PiggyBank, label: "Budgets" },
{ href: "/investments", icon: TrendingUp, label: "Investments" }, { href: "/investments", icon: TrendingUp, label: "Investments" },
{ href: "/reports", icon: BarChart3, label: "Reports" }, { href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/tax", icon: Receipt, label: "Tax" },
{ href: "/predictions", icon: Sparkles, label: "Predictions" }, { href: "/predictions", icon: Sparkles, label: "Predictions" },
{ href: "/settings", icon: Settings, label: "Settings" }, { href: "/settings", icon: Settings, label: "Settings" },
]; ];

View file

@ -23,7 +23,6 @@
--ring: 252 87% 67%; --ring: 252 87% 67%;
--radius: 0.6rem; --radius: 0.6rem;
--success: 142 71% 45%; --success: 142 71% 45%;
--warning: 38 92% 58%;
} }
/* ─── Base font ─────────────────────────────────────────────────────────────── */ /* ─── Base font ─────────────────────────────────────────────────────────────── */
@ -72,7 +71,6 @@
--ring: 252 87% 67%; --ring: 252 87% 67%;
--radius: 0.6rem; --radius: 0.6rem;
--success: 142 71% 45%; --success: 142 71% 45%;
--warning: 38 92% 58%;
} }
/* /*
@ -99,7 +97,6 @@
--ring: 252 87% 55%; --ring: 252 87% 55%;
--radius: 0.6rem; --radius: 0.6rem;
--success: 142 71% 40%; --success: 142 71% 40%;
--warning: 38 90% 46%;
} }
/* /*
@ -126,7 +123,6 @@
--ring: 252 87% 70%; --ring: 252 87% 70%;
--radius: 0.5rem; --radius: 0.5rem;
--success: 142 71% 45%; --success: 142 71% 45%;
--warning: 38 92% 62%;
} }
/* /*
@ -153,7 +149,6 @@
--ring: 38 92% 50%; --ring: 38 92% 50%;
--radius: 0.4rem; --radius: 0.4rem;
--success: 142 55% 42%; --success: 142 55% 42%;
--warning: 38 92% 58%;
} }
/* /*
@ -180,7 +175,6 @@
--ring: 120 100% 50%; --ring: 120 100% 50%;
--radius: 0.2rem; --radius: 0.2rem;
--success: 120 100% 50%; --success: 120 100% 50%;
--warning: 60 100% 55%;
} }
.theme-terminal body, .theme-terminal body,
@ -249,7 +243,6 @@
--ring: 330 100% 62%; --ring: 330 100% 62%;
--radius: 0.5rem; --radius: 0.5rem;
--success: 165 100% 45%; --success: 165 100% 45%;
--warning: 55 100% 65%;
} }
.theme-synthwave h1, .theme-synthwave h1,
@ -314,7 +307,6 @@
--ring: 0 65% 38%; --ring: 0 65% 38%;
--radius: 0.2rem; --radius: 0.2rem;
--success: 142 50% 32%; --success: 142 50% 32%;
--warning: 38 90% 46%;
} }
.theme-ledger body, .theme-ledger body,

View file

@ -95,13 +95,13 @@ export default function AccountDetail() {
<div className="bg-card border border-border rounded-xl p-4"> <div className="bg-card border border-border rounded-xl p-4">
<div className="flex justify-between text-sm mb-2"> <div className="flex justify-between text-sm mb-2">
<span className="font-medium">Credit Utilisation</span> <span className="font-medium">Credit Utilisation</span>
<span className={cn("font-semibold", utilPct > 80 ? "text-destructive" : utilPct > 50 ? "text-warning" : "text-success")}> <span className={cn("font-semibold", utilPct > 80 ? "text-destructive" : utilPct > 50 ? "text-yellow-500" : "text-success")}>
{utilPct.toFixed(0)}% {utilPct.toFixed(0)}%
</span> </span>
</div> </div>
<div className="h-2 bg-secondary rounded-full overflow-hidden"> <div className="h-2 bg-secondary rounded-full overflow-hidden">
<div <div
className={cn("h-full rounded-full transition-all", utilPct > 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}%` }} style={{ width: `${utilPct}%` }}
/> />
</div> </div>
@ -298,7 +298,7 @@ function ImportModal({
{/* Detected format badge */} {/* Detected format badge */}
<div className={cn( <div className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm", "flex items-center gap-2 px-3 py-2 rounded-lg text-sm",
preview.detected_format ? "bg-success/10 text-success" : "bg-warning/10 text-warning" preview.detected_format ? "bg-success/10 text-success" : "bg-yellow-500/10 text-yellow-600"
)}> )}>
{preview.detected_format ? ( {preview.detected_format ? (
<><CheckCircle className="w-4 h-4 shrink-0" /> Detected: <strong>{preview.detected_format}</strong></> <><CheckCircle className="w-4 h-4 shrink-0" /> Detected: <strong>{preview.detected_format}</strong></>

View file

@ -315,7 +315,7 @@ function AccountGroup({
</div> </div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden"> <div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div <div
className={cn("h-full rounded-full transition-all", utilPct > 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}%` }} style={{ width: `${utilPct}%` }}
/> />
</div> </div>

View file

@ -12,11 +12,7 @@ function RadialGauge({ percent, size = 80 }: { percent: number; size?: number })
const circumference = 2 * Math.PI * r; const circumference = 2 * Math.PI * r;
const clamped = Math.min(percent, 100); const clamped = Math.min(percent, 100);
const offset = circumference - (clamped / 100) * circumference; const offset = circumference - (clamped / 100) * circumference;
const color = percent >= 100 const color = percent >= 100 ? "#ef4444" : percent >= 80 ? "#f97316" : "#22c55e";
? "hsl(var(--destructive))"
: percent >= 80
? "hsl(var(--warning))"
: "hsl(var(--success))";
return ( return (
<svg width={size} height={size} className="shrink-0"> <svg width={size} height={size} className="shrink-0">
@ -109,7 +105,7 @@ export default function BudgetPage() {
<span className="ml-2 text-destructive font-medium">· {overBudget} over budget</span> <span className="ml-2 text-destructive font-medium">· {overBudget} over budget</span>
)} )}
{alerted > 0 && ( {alerted > 0 && (
<span className="ml-2 text-warning font-medium">· {alerted} near limit</span> <span className="ml-2 text-orange-500 font-medium">· {alerted} near limit</span>
)} )}
</p> </p>
</div> </div>
@ -143,7 +139,7 @@ export default function BudgetPage() {
item.is_over_budget item.is_over_budget
? "border-destructive/50" ? "border-destructive/50"
: item.alert_triggered : item.alert_triggered
? "border-warning/50" ? "border-orange-500/50"
: "border-border" : "border-border"
)} )}
> >
@ -174,7 +170,7 @@ export default function BudgetPage() {
{item.is_over_budget ? ( {item.is_over_budget ? (
<AlertTriangle className="w-3.5 h-3.5 text-destructive shrink-0" /> <AlertTriangle className="w-3.5 h-3.5 text-destructive shrink-0" />
) : item.alert_triggered ? ( ) : item.alert_triggered ? (
<AlertTriangle className="w-3.5 h-3.5 text-warning shrink-0" /> <AlertTriangle className="w-3.5 h-3.5 text-orange-500 shrink-0" />
) : ( ) : (
<CheckCircle className="w-3.5 h-3.5 text-success shrink-0" /> <CheckCircle className="w-3.5 h-3.5 text-success shrink-0" />
)} )}

View file

@ -18,20 +18,6 @@ import { Link } from "react-router-dom";
const COLORS = ["#6366f1","#22c55e","#f97316","#ec4899","#14b8a6","#f59e0b","#8b5cf6","#ef4444"]; 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<string, string> = { const TYPE_COLORS: Record<string, string> = {
income: "text-success", income: "text-success",
expense: "text-destructive", expense: "text-destructive",
@ -67,13 +53,13 @@ export default function Dashboard() {
{/* 2FA nudge */} {/* 2FA nudge */}
{!totpEnabled && ( {!totpEnabled && (
<div className="flex items-center gap-3 bg-warning/10 border border-warning/30 rounded-xl px-4 py-3"> <div className="flex items-center gap-3 bg-yellow-500/10 border border-yellow-500/30 rounded-xl px-4 py-3">
<ShieldAlert className="w-5 h-5 text-warning shrink-0" /> <ShieldAlert className="w-5 h-5 text-yellow-500 shrink-0" />
<p className="flex-1 text-sm"> <p className="flex-1 text-sm">
<span className="font-medium text-warning">Enable two-factor authentication</span> <span className="font-medium text-yellow-500">Enable two-factor authentication</span>
<span className="text-muted-foreground ml-1">to secure your account.</span> <span className="text-muted-foreground ml-1">to secure your account.</span>
</p> </p>
<Link to="/security/totp" className="text-xs text-warning underline underline-offset-2 shrink-0"> <Link to="/security/totp" className="text-xs text-yellow-500 underline underline-offset-2 shrink-0">
Set up 2FA Set up 2FA
</Link> </Link>
</div> </div>
@ -125,14 +111,14 @@ export default function Dashboard() {
<AreaChart data={nwReport.points.map(p => ({ date: p.date, value: Number(p.net_worth) }))}> <AreaChart data={nwReport.points.map(p => ({ date: p.date, value: Number(p.net_worth) }))}>
<defs> <defs>
<linearGradient id="nwGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="nwGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} /> <stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} /> <stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" /> <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${(v/1000).toFixed(0)}k`} width={45} /> <YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v/1000).toFixed(0)}k`} width={45} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, nwReport.base_currency)} /> <Tooltip formatter={(v: number) => formatCurrency(v, nwReport.base_currency)} />
<Area type="monotone" dataKey="value" stroke="hsl(var(--primary))" fill="url(#nwGrad)" strokeWidth={2} /> <Area type="monotone" dataKey="value" stroke="#6366f1" fill="url(#nwGrad)" strokeWidth={2} />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
@ -150,9 +136,9 @@ export default function Dashboard() {
{ieReport && ieReport.points.length > 0 ? ( {ieReport && ieReport.points.length > 0 ? (
<ResponsiveContainer width="100%" height={180}> <ResponsiveContainer width="100%" height={180}>
<BarChart data={ieReport.points.map(p => ({ month: p.month, income: Number(p.income), expenses: Number(p.expenses) }))}> <BarChart data={ieReport.points.map(p => ({ month: p.month, income: Number(p.income), expenses: Number(p.expenses) }))}>
<XAxis dataKey="month" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" /> <XAxis dataKey="month" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${(v/1000).toFixed(0)}k`} width={45} /> <YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v/1000).toFixed(0)}k`} width={45} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, "GBP")} /> <Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Bar dataKey="income" fill="#22c55e" radius={[2,2,0,0]} name="Income" /> <Bar dataKey="income" fill="#22c55e" radius={[2,2,0,0]} name="Income" />
<Bar dataKey="expenses" fill="#ef4444" radius={[2,2,0,0]} name="Expenses" /> <Bar dataKey="expenses" fill="#ef4444" radius={[2,2,0,0]} name="Expenses" />
</BarChart> </BarChart>
@ -180,7 +166,7 @@ export default function Dashboard() {
cx="50%" cy="50%" innerRadius={42} outerRadius={65} dataKey="value" paddingAngle={2}> cx="50%" cy="50%" innerRadius={42} outerRadius={65} dataKey="value" paddingAngle={2}>
{catReport.items.slice(0,8).map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)} {catReport.items.slice(0,8).map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie> </Pie>
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, "GBP")} /> <Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
<div className="flex-1 space-y-1.5 min-w-0"> <div className="flex-1 space-y-1.5 min-w-0">

View file

@ -6,7 +6,6 @@ import { useUiStore } from "@/store/uiStore";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react"; import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react";
import Plot from "react-plotly.js"; import Plot from "react-plotly.js";
import { cssVar } from "@/utils/cssVar";
export default function AssetDetail() { export default function AssetDetail() {
const { assetId } = useParams<{ assetId: string }>(); const { assetId } = useParams<{ assetId: string }>();
@ -153,8 +152,8 @@ export default function AssetDetail() {
high: highs as number[], high: highs as number[],
low: lows as number[], low: lows as number[],
close: closes as number[], close: closes as number[],
increasing: { line: { color: cssVar("--success") } }, increasing: { line: { color: "#22c55e" } },
decreasing: { line: { color: cssVar("--destructive") } }, decreasing: { line: { color: "#ef4444" } },
name: holding?.symbol ?? "Price", name: holding?.symbol ?? "Price",
}, },
{ {
@ -162,16 +161,16 @@ export default function AssetDetail() {
x: dates, x: dates,
y: volumes as number[], y: volumes as number[],
yaxis: "y2", yaxis: "y2",
marker: { color: cssVar("--primary", 0.3) }, marker: { color: "rgba(99,102,241,0.3)" },
name: "Volume", name: "Volume",
}, },
]} ]}
layout={{ layout={{
paper_bgcolor: "transparent", paper_bgcolor: "transparent",
plot_bgcolor: "transparent", plot_bgcolor: "transparent",
font: { color: cssVar("--muted-foreground"), size: 11 }, font: { color: "var(--muted-foreground)", size: 11 },
xaxis: { rangeslider: { visible: false }, gridcolor: cssVar("--border"), showgrid: true }, xaxis: { rangeslider: { visible: false }, gridcolor: "var(--border)", showgrid: true },
yaxis: { gridcolor: cssVar("--border"), showgrid: true, domain: [0.25, 1] }, yaxis: { gridcolor: "var(--border)", showgrid: true, domain: [0.25, 1] },
yaxis2: { domain: [0, 0.2], showgrid: false }, yaxis2: { domain: [0, 0.2], showgrid: false },
margin: { t: 10, r: 10, b: 40, l: 60 }, margin: { t: 10, r: 10, b: 40, l: 60 },
showlegend: false, showlegend: false,

View file

@ -10,20 +10,6 @@ const COLORS = [
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444", "#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<string, string> = { const TYPE_COLORS: Record<string, string> = {
stock: "#6366f1", stock: "#6366f1",
etf: "#22c55e", etf: "#22c55e",
@ -195,11 +181,16 @@ export function CostVsValueChart({ portfolio }: { portfolio: PortfolioSummary })
width={52} width={52}
/> />
<Tooltip <Tooltip
{...TOOLTIP_STYLE}
formatter={(value: number, name: string) => [ formatter={(value: number, name: string) => [
formatCurrency(value, portfolio.currency), formatCurrency(value, portfolio.currency),
name === "cost" ? "Cost basis" : "Current value", name === "cost" ? "Cost basis" : "Current value",
]} ]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
/> />
<Bar dataKey="cost" name="cost" fill="hsl(var(--muted-foreground))" opacity={0.4} radius={[4, 4, 0, 0]} barSize={20} /> <Bar dataKey="cost" name="cost" fill="hsl(var(--muted-foreground))" opacity={0.4} radius={[4, 4, 0, 0]} barSize={20} />
<Bar dataKey="value" name="value" radius={[4, 4, 0, 0]} barSize={20}> <Bar dataKey="value" name="value" radius={[4, 4, 0, 0]} barSize={20}>
@ -274,8 +265,13 @@ export function ReturnChart({ portfolio }: { portfolio: PortfolioSummary }) {
/> />
<ReferenceLine x={0} stroke="hsl(var(--border))" strokeWidth={1.5} /> <ReferenceLine x={0} stroke="hsl(var(--border))" strokeWidth={1.5} />
<Tooltip <Tooltip
{...TOOLTIP_STYLE}
formatter={(value: number) => [`${Number(value).toFixed(2)}%`, "Return"]} formatter={(value: number) => [`${Number(value).toFixed(2)}%`, "Return"]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
/> />
<Bar dataKey="pct" radius={[0, 4, 4, 0]} barSize={18}> <Bar dataKey="pct" radius={[0, 4, 4, 0]} barSize={18}>
<LabelList <LabelList

View file

@ -12,7 +12,6 @@ import {
XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, ReferenceLine, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, ReferenceLine,
} from "recharts"; } from "recharts";
import Plot from "react-plotly.js"; import Plot from "react-plotly.js";
import { cssVar } from "@/utils/cssVar";
const TABS = [ const TABS = [
{ id: "spending", label: "Spending", icon: BarChart3 }, { id: "spending", label: "Spending", icon: BarChart3 },
@ -106,11 +105,11 @@ function SpendingTab() {
</div> </div>
<ResponsiveContainer width="100%" height={260}> <ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}> <BarChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" /> <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${v}`} width={55} /> <YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${v}`} width={55} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} /> <Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Bar dataKey="actual" fill="hsl(var(--primary))" name="Actual" radius={[2, 2, 0, 0]} /> <Bar dataKey="actual" fill="#6366f1" name="Actual" radius={[2, 2, 0, 0]} />
<Bar dataKey="forecast" fill="hsl(var(--primary) / 0.5)" name="Forecast" radius={[2, 2, 0, 0]} /> <Bar dataKey="forecast" fill="#6366f180" name="Forecast" radius={[2, 2, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@ -147,13 +146,13 @@ function BudgetAlerts() {
<div key={f.category_id}> <div key={f.category_id}>
<div className="flex justify-between text-sm mb-1"> <div className="flex justify-between text-sm mb-1">
<span className="font-medium">{f.category_name}</span> <span className="font-medium">{f.category_name}</span>
<span className={cn("text-xs font-medium", f.probability_overspend > 0.75 ? "text-destructive" : "text-warning")}> <span className={cn("text-xs font-medium", f.probability_overspend > 0.75 ? "text-destructive" : "text-yellow-500")}>
{(f.probability_overspend * 100).toFixed(0)}% overspend risk {(f.probability_overspend * 100).toFixed(0)}% overspend risk
</span> </span>
</div> </div>
<div className="h-2 bg-secondary rounded-full overflow-hidden relative"> <div className="h-2 bg-secondary rounded-full overflow-hidden relative">
<div <div
className={cn("h-full rounded-full", f.probability_overspend > 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)}%` }} style={{ width: `${Math.min(100, forecastPct)}%` }}
/> />
<div className="absolute top-0 h-full w-0.5 bg-foreground/40" style={{ left: "100%" }} /> <div className="absolute top-0 h-full w-0.5 bg-foreground/40" style={{ left: "100%" }} />
@ -239,12 +238,12 @@ function NetWorthTab() {
<p className="text-sm font-semibold mb-4">Net Worth Projection</p> <p className="text-sm font-semibold mb-4">Net Worth Projection</p>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}> <LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" interval="preserveStartEnd" /> <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${(v / 1000).toFixed(0)}k`} width={55} /> <YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v / 1000).toFixed(0)}k`} width={55} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} /> <Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Legend /> <Legend />
{lastHistory && <ReferenceLine x={lastHistory.date} stroke="hsl(var(--border))" strokeDasharray="4 2" label={{ value: "Today", fontSize: 10 }} />} {lastHistory && <ReferenceLine x={lastHistory.date} stroke="var(--border)" strokeDasharray="4 2" label={{ value: "Today", fontSize: 10 }} />}
<Line type="monotone" dataKey="history" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} name="History" /> <Line type="monotone" dataKey="history" stroke="#6366f1" strokeWidth={2} dot={false} name="History" />
<Line type="monotone" dataKey="conservative" stroke="#ef4444" strokeWidth={1.5} strokeDasharray="4 2" dot={false} name="Conservative" /> <Line type="monotone" dataKey="conservative" stroke="#ef4444" strokeWidth={1.5} strokeDasharray="4 2" dot={false} name="Conservative" />
<Line type="monotone" dataKey="base" stroke="#22c55e" strokeWidth={2} strokeDasharray="4 2" dot={false} name="Base" /> <Line type="monotone" dataKey="base" stroke="#22c55e" strokeWidth={2} strokeDasharray="4 2" dot={false} name="Base" />
<Line type="monotone" dataKey="optimistic" stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="4 2" dot={false} name="Optimistic" /> <Line type="monotone" dataKey="optimistic" stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="4 2" dot={false} name="Optimistic" />
@ -343,8 +342,8 @@ function MonteCarloTab() {
x: data.percentiles.p90.map(p => p.date), x: data.percentiles.p90.map(p => p.date),
y: data.percentiles.p90.map(p => p.value), y: data.percentiles.p90.map(p => p.value),
fill: "tonexty", fill: "tonexty",
fillcolor: cssVar("--primary", 0.15), fillcolor: "rgba(99,102,241,0.15)",
line: { color: cssVar("--primary"), width: 1 }, line: { color: "#6366f1", width: 1 },
name: "P90", name: "P90",
mode: "lines", mode: "lines",
}, },
@ -353,8 +352,8 @@ function MonteCarloTab() {
x: data.percentiles.p75.map(p => p.date), x: data.percentiles.p75.map(p => p.date),
y: data.percentiles.p75.map(p => p.value), y: data.percentiles.p75.map(p => p.value),
fill: "tonexty", fill: "tonexty",
fillcolor: cssVar("--primary", 0.2), fillcolor: "rgba(99,102,241,0.2)",
line: { color: cssVar("--primary"), width: 1 }, line: { color: "#6366f1", width: 1 },
name: "P75", name: "P75",
mode: "lines", mode: "lines",
}, },
@ -362,7 +361,7 @@ function MonteCarloTab() {
type: "scatter" as const, type: "scatter" as const,
x: data.percentiles.p50.map(p => p.date), x: data.percentiles.p50.map(p => p.date),
y: data.percentiles.p50.map(p => p.value), y: data.percentiles.p50.map(p => p.value),
line: { color: cssVar("--success"), width: 2.5 }, line: { color: "#22c55e", width: 2.5 },
name: "P50 (Median)", name: "P50 (Median)",
mode: "lines", mode: "lines",
}, },
@ -371,8 +370,8 @@ function MonteCarloTab() {
x: data.percentiles.p25.map(p => p.date), x: data.percentiles.p25.map(p => p.date),
y: data.percentiles.p25.map(p => p.value), y: data.percentiles.p25.map(p => p.value),
fill: "tonexty", fill: "tonexty",
fillcolor: cssVar("--destructive", 0.1), fillcolor: "rgba(239,68,68,0.1)",
line: { color: cssVar("--destructive"), width: 1 }, line: { color: "#ef4444", width: 1 },
name: "P25", name: "P25",
mode: "lines", mode: "lines",
}, },
@ -381,8 +380,8 @@ function MonteCarloTab() {
x: data.percentiles.p10.map(p => p.date), x: data.percentiles.p10.map(p => p.date),
y: data.percentiles.p10.map(p => p.value), y: data.percentiles.p10.map(p => p.value),
fill: "tonexty", fill: "tonexty",
fillcolor: cssVar("--destructive", 0.15), fillcolor: "rgba(239,68,68,0.15)",
line: { color: cssVar("--destructive"), width: 1 }, line: { color: "#ef4444", width: 1 },
name: "P10", name: "P10",
mode: "lines", mode: "lines",
}, },
@ -390,10 +389,10 @@ function MonteCarloTab() {
layout={{ layout={{
paper_bgcolor: "transparent", paper_bgcolor: "transparent",
plot_bgcolor: "transparent", plot_bgcolor: "transparent",
font: { color: cssVar("--muted-foreground"), size: 11 }, font: { color: "var(--muted-foreground)", size: 11 },
xaxis: { gridcolor: cssVar("--border"), showgrid: true }, xaxis: { gridcolor: "var(--border)", showgrid: true },
yaxis: { yaxis: {
gridcolor: cssVar("--border"), gridcolor: "var(--border)",
showgrid: true, showgrid: true,
tickformat: "£,.0f", tickformat: "£,.0f",
}, },
@ -462,15 +461,15 @@ function CashFlowTab() {
<AreaChart data={data.forecast} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}> <AreaChart data={data.forecast} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<defs> <defs>
<linearGradient id="balanceGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="balanceGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} /> <stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} /> <stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => v.slice(5)} /> <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => v.slice(5)} />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${(v / 1000).toFixed(1)}k`} width={55} /> <YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v / 1000).toFixed(1)}k`} width={55} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} /> <Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<ReferenceLine y={0} stroke="hsl(var(--destructive))" strokeDasharray="4 2" /> <ReferenceLine y={0} stroke="#ef4444" strokeDasharray="4 2" />
<Area type="monotone" dataKey="balance" stroke="hsl(var(--primary))" fill="url(#balanceGrad)" strokeWidth={2} name="Balance" /> <Area type="monotone" dataKey="balance" stroke="#6366f1" fill="url(#balanceGrad)" strokeWidth={2} name="Balance" />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View file

@ -37,20 +37,6 @@ const COLORS = [
const ASSET_COLORS = ["#22c55e", "#14b8a6", "#6366f1", "#8b5cf6", "#06b6d4", "#84cc16"]; const ASSET_COLORS = ["#22c55e", "#14b8a6", "#6366f1", "#8b5cf6", "#06b6d4", "#84cc16"];
const LIABILITY_COLORS = ["#ef4444", "#f97316", "#ec4899"]; 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 }: { function StatCard({ label, value, change, currency }: {
label: string; value: number; change?: number; currency: string; label: string; value: number; change?: number; currency: string;
}) { }) {
@ -263,15 +249,15 @@ function NetWorthTab() {
<AreaChart data={data.points.map(p => ({ ...p, net_worth: Number(p.net_worth), total_assets: Number(p.total_assets), total_liabilities: Number(p.total_liabilities) }))}> <AreaChart data={data.points.map(p => ({ ...p, net_worth: Number(p.net_worth), total_assets: Number(p.total_assets), total_liabilities: Number(p.total_liabilities) }))}>
<defs> <defs>
<linearGradient id="nwGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="nwGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} /> <stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} /> <stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" /> <XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} /> <YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.base_currency)} /> <Tooltip formatter={(v: number) => formatCurrency(v, data.base_currency)} />
<Area type="monotone" dataKey="net_worth" stroke="hsl(var(--primary))" fill="url(#nwGrad)" strokeWidth={2} name="Net Worth" /> <Area type="monotone" dataKey="net_worth" stroke="#6366f1" fill="url(#nwGrad)" strokeWidth={2} name="Net Worth" />
<Area type="monotone" dataKey="total_assets" stroke="#22c55e" fill="none" strokeWidth={1.5} strokeDasharray="4 2" name="Assets" /> <Area type="monotone" dataKey="total_assets" stroke="#22c55e" fill="none" strokeWidth={1.5} strokeDasharray="4 2" name="Assets" />
<Area type="monotone" dataKey="total_liabilities" stroke="#ef4444" fill="none" strokeWidth={1.5} strokeDasharray="4 2" name="Liabilities" /> <Area type="monotone" dataKey="total_liabilities" stroke="#ef4444" fill="none" strokeWidth={1.5} strokeDasharray="4 2" name="Liabilities" />
</AreaChart> </AreaChart>
@ -301,10 +287,10 @@ function IncomeExpenseTab() {
<p className="text-sm font-medium mb-4">Monthly Income vs Expenses</p> <p className="text-sm font-medium mb-4">Monthly Income vs Expenses</p>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}> <BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" /> <XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} /> <YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.currency)} /> <Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend /> <Legend />
<Bar dataKey="income" fill="#22c55e" name="Income" radius={[2, 2, 0, 0]} /> <Bar dataKey="income" fill="#22c55e" name="Income" radius={[2, 2, 0, 0]} />
<Bar dataKey="expenses" fill="#ef4444" name="Expenses" radius={[2, 2, 0, 0]} /> <Bar dataKey="expenses" fill="#ef4444" name="Expenses" radius={[2, 2, 0, 0]} />
@ -354,15 +340,15 @@ function CashFlowTab() {
<p className="text-sm font-medium mb-4">Daily Cash Flow Last 30 Days</p> <p className="text-sm font-medium mb-4">Daily Cash Flow Last 30 Days</p>
<ResponsiveContainer width="100%" height={320}> <ResponsiveContainer width="100%" height={320}>
<ComposedChart data={chartData}> <ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" /> <XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis yAxisId="bars" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} /> <YAxis yAxisId="bars" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} />
<YAxis yAxisId="line" orientation="right" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} /> <YAxis yAxisId="line" orientation="right" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.currency)} /> <Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend /> <Legend />
<Bar yAxisId="bars" dataKey="inflow" fill="#22c55e" name="Inflow" radius={[2, 2, 0, 0]} /> <Bar yAxisId="bars" dataKey="inflow" fill="#22c55e" name="Inflow" radius={[2, 2, 0, 0]} />
<Bar yAxisId="bars" dataKey="outflow" fill="#ef4444" name="Outflow" radius={[2, 2, 0, 0]} /> <Bar yAxisId="bars" dataKey="outflow" fill="#ef4444" name="Outflow" radius={[2, 2, 0, 0]} />
<Line yAxisId="line" type="monotone" dataKey="balance" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} name="Running Balance" /> <Line yAxisId="line" type="monotone" dataKey="balance" stroke="#6366f1" strokeWidth={2} dot={false} name="Running Balance" />
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@ -418,15 +404,15 @@ function SavingsRateTab() {
<p className="text-sm font-medium mb-4">Savings Rate by Month</p> <p className="text-sm font-medium mb-4">Savings Rate by Month</p>
<ResponsiveContainer width="100%" height={320}> <ResponsiveContainer width="100%" height={320}>
<ComposedChart data={chartData}> <ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" /> <XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis yAxisId="bars" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} /> <YAxis yAxisId="bars" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<YAxis yAxisId="rate" orientation="right" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `${v}%`} /> <YAxis yAxisId="rate" orientation="right" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `${v}%`} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number, name: string) => name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} /> <Tooltip formatter={(v: number, name: string) => name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} />
<Legend /> <Legend />
<Bar yAxisId="bars" dataKey="income" fill="#22c55e" name="Income" radius={[2, 2, 0, 0]} /> <Bar yAxisId="bars" dataKey="income" fill="#22c55e" name="Income" radius={[2, 2, 0, 0]} />
<Bar yAxisId="bars" dataKey="expenses" fill="#ef4444" name="Expenses" radius={[2, 2, 0, 0]} /> <Bar yAxisId="bars" dataKey="expenses" fill="#ef4444" name="Expenses" radius={[2, 2, 0, 0]} />
<Line yAxisId="rate" type="monotone" dataKey="rate" stroke="hsl(var(--primary))" strokeWidth={2.5} dot={{ r: 3 }} name="Savings Rate %" /> <Line yAxisId="rate" type="monotone" dataKey="rate" stroke="#6366f1" strokeWidth={2.5} dot={{ r: 3 }} name="Savings Rate %" />
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@ -502,7 +488,7 @@ function CategoriesTab() {
/> />
))} ))}
</Pie> </Pie>
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.currency)} /> <Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
<div className="flex-1 space-y-2 min-w-48"> <div className="flex-1 space-y-2 min-w-48">
@ -594,12 +580,12 @@ function BudgetVsActualTab() {
<p className="text-sm font-medium mb-4">Budget vs Actual Spending</p> <p className="text-sm font-medium mb-4">Budget vs Actual Spending</p>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData} layout="vertical"> <BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${v}`} /> <XAxis type="number" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${v}`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" width={120} /> <YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" width={120} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.currency)} /> <Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend /> <Legend />
<Bar dataKey="budgeted" fill="hsl(var(--primary))" name="Budgeted" radius={[0, 2, 2, 0]} /> <Bar dataKey="budgeted" fill="#6366f1" name="Budgeted" radius={[0, 2, 2, 0]} />
<Bar dataKey="actual" fill="#f97316" name="Actual" radius={[0, 2, 2, 0]} /> <Bar dataKey="actual" fill="#f97316" name="Actual" radius={[0, 2, 2, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@ -628,10 +614,10 @@ function SpendingTrendsTab() {
<p className="text-sm font-medium mb-4">Spending by Category (6 months)</p> <p className="text-sm font-medium mb-4">Spending by Category (6 months)</p>
<ResponsiveContainer width="100%" height={320}> <ResponsiveContainer width="100%" height={320}>
<BarChart data={chartData}> <BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" /> <XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${v}`} /> <YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${v}`} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.currency)} /> <Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend /> <Legend />
{data.categories.slice(0, 8).map((cat, i) => ( {data.categories.slice(0, 8).map((cat, i) => (
<Bar key={cat} dataKey={cat} stackId="a" fill={COLORS[i % COLORS.length]} /> <Bar key={cat} dataKey={cat} stackId="a" fill={COLORS[i % COLORS.length]} />
@ -688,10 +674,10 @@ function InvestmentsTab() {
<p className="text-sm font-medium mb-4">Holdings Value</p> <p className="text-sm font-medium mb-4">Holdings Value</p>
<ResponsiveContainer width="100%" height={220}> <ResponsiveContainer width="100%" height={220}>
<BarChart data={holdingsData} layout="vertical"> <BarChart data={holdingsData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} /> <XAxis type="number" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" width={60} /> <YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" width={60} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, perf.currency)} /> <Tooltip formatter={(v: number) => formatCurrency(v, perf.currency)} />
<Bar dataKey="value" name="Current Value" radius={[0, 3, 3, 0]}> <Bar dataKey="value" name="Current Value" radius={[0, 3, 3, 0]}>
{holdingsData.map((entry, i) => ( {holdingsData.map((entry, i) => (
<Cell key={i} fill={entry.gain >= 0 ? "#22c55e" : "#ef4444"} /> <Cell key={i} fill={entry.gain >= 0 ? "#22c55e" : "#ef4444"} />

View file

@ -234,7 +234,7 @@ function PasswordCard() {
<div className="mt-2 flex gap-1"> <div className="mt-2 flex gap-1">
{[1,2,3,4].map(i => { {[1,2,3,4].map(i => {
const score = Math.min(4, Math.floor(next.length / 3)); const score = Math.min(4, Math.floor(next.length / 3));
return <div key={i} className={cn("h-1 flex-1 rounded-full transition-colors", i <= score ? (score <= 1 ? "bg-destructive" : score <= 2 ? "bg-warning" : score <= 3 ? "bg-primary" : "bg-success") : "bg-secondary")} />; return <div key={i} className={cn("h-1 flex-1 rounded-full transition-colors", i <= score ? (score <= 1 ? "bg-destructive" : score <= 2 ? "bg-yellow-500" : score <= 3 ? "bg-primary" : "bg-success") : "bg-secondary")} />;
})} })}
</div> </div>
)} )}

View file

@ -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 (
<span className="flex items-center gap-1 text-xs font-medium text-destructive bg-destructive/10 border border-destructive/20 rounded-full px-2 py-0.5 whitespace-nowrap">
<AlertCircle className="w-3 h-3" /> Overdue
</span>
);
}
if (days === 0) {
return (
<span className="flex items-center gap-1 text-xs font-medium text-warning bg-warning/10 border border-warning/20 rounded-full px-2 py-0.5 whitespace-nowrap">
<CalendarClock className="w-3 h-3" /> Today
</span>
);
}
if (days <= 7) {
return (
<span className="flex items-center gap-1 text-xs font-medium text-warning bg-warning/10 border border-warning/20 rounded-full px-2 py-0.5 whitespace-nowrap">
<CalendarClock className="w-3 h-3" /> {days}d
</span>
);
}
return (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{format(parseISO(nextExpected), "d MMM")}
</span>
);
}
function FrequencyBadge({ frequency }: { frequency: string }) {
const labels: Record<string, string> = {
weekly: "Weekly",
fortnightly: "Fortnightly",
monthly: "Monthly",
quarterly: "Quarterly",
yearly: "Yearly",
unknown: "Unknown",
};
return (
<span className="text-xs bg-secondary text-muted-foreground rounded-full px-2 py-0.5">
{labels[frequency] ?? frequency}
</span>
);
}
function SubscriptionRow({
sub,
onUnmark,
}: {
sub: Subscription;
onUnmark: (id: string) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="flex items-center gap-4 px-4 py-3 border-b border-border/50 last:border-0 hover:bg-secondary/20 transition-colors group">
{/* Icon */}
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<RefreshCw className="w-4 h-4 text-primary" />
</div>
{/* Name + account */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{sub.name}</p>
<div className="flex items-center gap-2 mt-0.5">
<FrequencyBadge frequency={sub.frequency} />
{sub.account_name && (
<span className="text-xs text-muted-foreground truncate">{sub.account_name}</span>
)}
{sub.manually_set && (
<span className="text-xs text-muted-foreground italic">manual</span>
)}
</div>
</div>
{/* Last paid */}
<div className="hidden md:block text-xs text-muted-foreground text-right shrink-0">
<p className="text-muted-foreground/70">Last paid</p>
<p>{sub.last_paid ? format(parseISO(sub.last_paid), "d MMM yyyy") : "—"}</p>
</div>
{/* Next expected */}
<div className="hidden sm:flex flex-col items-end gap-0.5 shrink-0">
<p className="text-xs text-muted-foreground/70">Next</p>
{nextBadge(sub.next_expected)}
</div>
{/* Amount */}
<div className="text-right shrink-0">
<p className="font-semibold tabular-nums text-destructive">
{formatCurrency(Math.abs(sub.amount), "GBP")}
</p>
<p className="text-xs text-muted-foreground">
{formatCurrency(sub.monthly_equivalent, "GBP")}/mo
</p>
</div>
{/* Actions */}
<div className="relative shrink-0">
<button
onClick={() => setMenuOpen((o) => !o)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded hover:bg-secondary text-muted-foreground"
>
<span className="text-lg leading-none">···</span>
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-8 z-20 bg-card border border-border rounded-lg shadow-lg py-1 min-w-44">
<button
onClick={() => { setMenuOpen(false); onUnmark(sub.latest_transaction_id); }}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-secondary/50 transition-colors"
>
<Ban className="w-4 h-4" /> Mark as not recurring
</button>
</div>
</>
)}
</div>
</div>
);
}
export default function SubscriptionsPage() {
const qc = useQueryClient();
const [sort, setSort] = useState<SortKey>("next");
const [rescanMsg, setRescanMsg] = useState<string | null>(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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Subscriptions</h1>
<p className="text-sm text-muted-foreground mt-1">
Direct debits, standing orders, and recurring payments
</p>
</div>
<button
onClick={() => rescanMutation.mutate()}
disabled={rescanMutation.isPending}
className="flex items-center gap-2 border border-border px-3 py-2 rounded-lg text-sm hover:bg-secondary disabled:opacity-50 transition-colors shrink-0"
>
{rescanMutation.isPending
? <Loader2 className="w-4 h-4 animate-spin" />
: <RefreshCw className="w-4 h-4" />}
Re-scan
</button>
</div>
{rescanMsg && (
<div className="bg-primary/10 border border-primary/20 rounded-lg px-4 py-2.5 text-sm text-primary">
{rescanMsg}
</div>
)}
{/* Summary card */}
{data && (
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-xs text-muted-foreground mb-1">Estimated monthly recurring spend</p>
<p className="text-3xl font-bold tabular-nums text-destructive">
{formatCurrency(data.total_monthly_equivalent, data.currency)}
</p>
<p className="text-xs text-muted-foreground mt-1">
{data.subscriptions.length} recurring payment{data.subscriptions.length !== 1 ? "s" : ""} detected
</p>
</div>
)}
{/* Sort */}
{(data?.subscriptions.length ?? 0) > 0 && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Sort:</span>
{(["next", "amount", "name"] as SortKey[]).map((key) => (
<button
key={key}
onClick={() => setSort(key)}
className={cn(
"px-3 py-1 rounded-lg border text-xs font-medium transition-colors",
sort === key
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
{key === "next" ? "Next payment" : key === "amount" ? "Amount" : "Name"}
</button>
))}
</div>
)}
{/* List */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="space-y-px">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-secondary/30 animate-pulse" />
))}
</div>
) : sorted.length === 0 ? (
<div className="py-16 text-center text-muted-foreground space-y-3">
<RefreshCw className="w-10 h-10 mx-auto opacity-20" />
<p className="font-medium">No recurring transactions detected</p>
<p className="text-sm">Import a few months of bank statements, then hit Re-scan.</p>
</div>
) : (
sorted.map((sub) => (
<SubscriptionRow
key={`${sub.name}|${sub.amount}|${sub.frequency}`}
sub={sub}
onUnmark={(id) => unmarkMutation.mutate(id)}
/>
))
)}
</div>
</div>
);
}

View file

@ -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 (
<span className={`tabular-nums ${n > 0 ? "text-green-500" : n < 0 ? "text-destructive" : ""}`}>
{gbp(v)}
</span>
);
}
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<ManualDisposal | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(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<ManualDisposalCreate> }) =>
updateCgtDisposal(id, data),
onSuccess: () => { invalidate(); setEditing(null); },
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteCgtDisposal(id),
onSuccess: () => { invalidate(); setDeletingId(null); },
});
return (
<>
<div className="rounded-lg border border-border bg-card p-6 space-y-6">
<h2 className="text-base font-semibold">Capital Gains Tax</h2>
{/* Mid-year rate change note for 2024/25 */}
{taxYear === 2025 && (
<div className="flex items-start gap-2 rounded-md border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>
<strong>2024/25 note:</strong> 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.
</span>
</div>
)}
{/* Summary */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ 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 }) => (
<div key={label} className="rounded-md bg-secondary/40 px-4 py-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm tabular-nums ${bold ? "font-semibold" : ""}`}>{gbp(value)}</p>
</div>
))}
</div>
{/* Band breakdown */}
{cgt.band_breakdown.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Band Breakdown</p>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b border-border">
<th className="text-left pb-1 font-normal">Rate</th>
<th className="text-right pb-1 font-normal">Taxable</th>
<th className="text-right pb-1 font-normal">Tax</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{cgt.band_breakdown.map((b, i) => (
<tr key={i}>
<td className="py-1 text-muted-foreground">{pct(b.rate)}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.taxable))}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.tax))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Auto-detected disposals */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Investment Disposals (auto-detected)
</p>
{cgt.investment_disposals.length === 0 ? (
<p className="text-sm text-muted-foreground">No investment disposals detected for this tax year.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground text-xs border-b border-border">
<th className="text-left pb-2 font-normal">Date</th>
<th className="text-left pb-2 font-normal">Asset</th>
<th className="text-right pb-2 font-normal">Proceeds</th>
<th className="text-right pb-2 font-normal">Cost Basis</th>
<th className="text-right pb-2 font-normal">Gain / Loss</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{cgt.investment_disposals.map((d, i) => (
<tr key={i} className="hover:bg-secondary/30 transition-colors">
<td className="py-2 text-muted-foreground">{fmtDate(d.date)}</td>
<td className="py-2">
<p className="font-medium">{d.asset}</p>
<p className="text-xs text-muted-foreground">{d.symbol} · {d.quantity} units</p>
</td>
<td className="py-2 text-right tabular-nums">{gbp(d.proceeds)}</td>
<td className="py-2 text-right tabular-nums">{gbp(d.cost_basis)}</td>
<td className="py-2 text-right"><GainCell v={d.gain_loss} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Manual disposals */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Manual Disposals
</p>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<Plus className="w-3.5 h-3.5" />
Add
</button>
</div>
{cgt.manual_disposals.length === 0 ? (
<p className="text-sm text-muted-foreground">No manual disposals add any non-investment capital gains here (e.g. property).</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground text-xs border-b border-border">
<th className="text-left pb-2 font-normal">Date</th>
<th className="text-left pb-2 font-normal">Asset</th>
<th className="text-right pb-2 font-normal">Proceeds</th>
<th className="text-right pb-2 font-normal">Cost</th>
<th className="text-right pb-2 font-normal">Gain / Loss</th>
<th className="text-right pb-2 font-normal">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{cgt.manual_disposals.map(d => (
<tr key={d.id} className="hover:bg-secondary/30 transition-colors">
<td className="py-2 text-muted-foreground">{fmtDate(d.disposal_date)}</td>
<td className="py-2 font-medium">{d.asset_description}</td>
<td className="py-2 text-right tabular-nums">{gbp(d.proceeds)}</td>
<td className="py-2 text-right tabular-nums">{gbp(d.cost_basis)}</td>
<td className="py-2 text-right"><GainCell v={d.gain_loss} /></td>
<td className="py-2 text-right">
{deletingId === d.id ? (
<span className="flex items-center justify-end gap-2 text-xs">
<span className="text-muted-foreground">Delete?</span>
<button onClick={() => deleteMut.mutate(d.id)} className="text-destructive hover:underline">Yes</button>
<button onClick={() => setDeletingId(null)} className="text-muted-foreground hover:underline">No</button>
</span>
) : (
<span className="flex items-center justify-end gap-2">
<button onClick={() => setEditing(d)} className="text-muted-foreground hover:text-foreground transition-colors">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => setDeletingId(d.id)} className="text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{showAdd && (
<ManualDisposalFormModal
onClose={() => setShowAdd(false)}
onSubmit={data => createMut.mutate(data)}
isLoading={createMut.isPending}
/>
)}
{editing && (
<ManualDisposalFormModal
disposal={editing}
onClose={() => setEditing(null)}
onSubmit={data => updateMut.mutate({ id: editing.id, data })}
isLoading={updateMut.isPending}
/>
)}
</>
);
}

View file

@ -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 (
<div className="rounded-lg border border-border bg-card p-6 space-y-6">
<h2 className="text-base font-semibold">Dividends</h2>
{/* Summary */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ 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 }) => (
<div key={label} className="rounded-md bg-secondary/40 px-4 py-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm tabular-nums ${bold ? "font-semibold" : ""}`}>{gbp(value)}</p>
</div>
))}
</div>
{/* Band breakdown */}
{div.band_breakdown.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Band Breakdown</p>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b border-border">
<th className="text-left pb-1 font-normal">Rate</th>
<th className="text-right pb-1 font-normal">Taxable</th>
<th className="text-right pb-1 font-normal">Tax</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{div.band_breakdown.map((b, i) => (
<tr key={i}>
<td className="py-1 text-muted-foreground">{pct(b.rate)}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.taxable))}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.tax))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Dividend transactions */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Dividend Transactions (auto-detected)
</p>
{div.dividend_transactions.length === 0 ? (
<p className="text-sm text-muted-foreground">No dividend income detected for this tax year.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground text-xs border-b border-border">
<th className="text-left pb-2 font-normal">Date</th>
<th className="text-left pb-2 font-normal">Asset</th>
<th className="text-right pb-2 font-normal">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{div.dividend_transactions.map((t, i) => (
<tr key={i} className="hover:bg-secondary/30 transition-colors">
<td className="py-2 text-muted-foreground">{fmtDate(t.date)}</td>
<td className="py-2">
<p className="font-medium">{t.asset}</p>
<p className="text-xs text-muted-foreground">{t.symbol}</p>
</td>
<td className="py-2 text-right tabular-nums text-green-500">{gbp(t.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View file

@ -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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-semibold">{isEdit ? "Edit Disposal" : "Add Manual Disposal"}</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} noValidate className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Disposal Date</label>
<input type="date" value={form.disposal_date} onChange={e => set("disposal_date", e.target.value)} className={inp} />
</div>
<div className="col-span-2 sm:col-span-1">
<label className="text-sm font-medium block mb-1.5">Asset Description</label>
<input value={form.asset_description} onChange={e => set("asset_description", e.target.value)} className={inp} placeholder="e.g. Rental property" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Proceeds (£)</label>
<input type="number" step="0.01" value={form.proceeds} onChange={e => set("proceeds", e.target.value)} className={inp} placeholder="0.00" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Cost Basis (£)</label>
<input type="number" step="0.01" value={form.cost_basis} onChange={e => set("cost_basis", e.target.value)} className={inp} placeholder="0.00" />
</div>
</div>
{form.proceeds && form.cost_basis && !isNaN(parseFloat(form.proceeds)) && !isNaN(parseFloat(form.cost_basis)) && (
<p className="text-sm text-muted-foreground">
Gain / Loss:{" "}
<span className={parseFloat(form.proceeds) - parseFloat(form.cost_basis) >= 0 ? "text-green-500" : "text-destructive"}>
{new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(
parseFloat(form.proceeds) - parseFloat(form.cost_basis)
)}
</span>
</p>
)}
<div>
<label className="text-sm font-medium block mb-1.5">Notes</label>
<input value={form.notes} onChange={e => set("notes", e.target.value)} className={inp} placeholder="Optional" />
</div>
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors">
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{isEdit ? "Save Changes" : "Add Disposal"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -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 (
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-base font-semibold mb-6">Overall Tax Position {taxYearDisplay(report.tax_year)}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 items-start">
{/* Big numbers */}
<div className="sm:col-span-2 grid grid-cols-2 gap-4">
<div className="rounded-lg bg-secondary/40 p-4">
<p className="text-xs text-muted-foreground mb-1">Total Liability</p>
<p className="text-2xl font-bold tabular-nums">{gbp(s.total_liability)}</p>
</div>
<div className="rounded-lg bg-secondary/40 p-4">
<p className="text-xs text-muted-foreground mb-1">Already Withheld</p>
<p className="text-2xl font-bold tabular-nums">{gbp(s.total_withheld)}</p>
</div>
<div className="rounded-lg border-2 border-primary/30 bg-primary/5 p-4 col-span-2">
<p className={`text-xs mb-1 font-medium ${netColor}`}>{netLabel}</p>
<p className={`text-3xl font-bold tabular-nums ${netColor}`}>{gbp(s.net_owed)}</p>
{!isZero && (
<p className="text-xs text-muted-foreground mt-1">
{s.overpaid
? "HMRC should refund this amount (verify via self-assessment or payroll)"
: "You may owe this via self-assessment — verify with HMRC"}
</p>
)}
</div>
</div>
{/* Liability breakdown */}
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Breakdown</p>
{breakdown.map(({ label, value }) => (
<div key={label} className="flex justify-between items-baseline">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm tabular-nums font-medium">{gbp(value)}</span>
</div>
))}
<div className="flex justify-between items-baseline pt-2 border-t border-border">
<span className="text-sm font-semibold">Total</span>
<span className="text-sm font-semibold tabular-nums">{gbp(s.total_liability)}</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-semibold">Enter P60 Totals</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} noValidate className="p-6 space-y-4">
<p className="text-sm text-muted-foreground">
Enter the year-end totals from your P60. These are cumulative figures for the full tax year.
</p>
{existingCount > 0 && (
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
This will permanently replace your {existingCount} existing payslip{existingCount !== 1 ? "s" : ""} for this tax year with a single P60 entry.
</span>
</div>
)}
<div>
<label className="text-sm font-medium block mb-1.5">Total Gross Pay (£)</label>
<input type="number" step="0.01" value={form.gross_pay} onChange={e => set("gross_pay", e.target.value)} className={inp} placeholder="0.00" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Income Tax Withheld (£)</label>
<input type="number" step="0.01" value={form.income_tax_withheld} onChange={e => set("income_tax_withheld", e.target.value)} className={inp} placeholder="0.00" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">NI Withheld (£)</label>
<input type="number" step="0.01" value={form.ni_withheld} onChange={e => set("ni_withheld", e.target.value)} className={inp} placeholder="0.00" />
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Net Pay (£)</label>
<input type="number" step="0.01" value={form.net_pay} onChange={e => set("net_pay", e.target.value)} className={inp} placeholder="0.00" />
</div>
{existingCount > 0 && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={e => setConfirmed(e.target.checked)}
className="rounded"
/>
<span className="text-sm">I understand the existing payslips will be deleted</span>
</label>
)}
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors">
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
Enter P60
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-semibold">{isEdit ? "Edit Payslip" : "Add Payslip"}</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} noValidate className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Month</label>
<select value={form.period_month} onChange={e => set("period_month", e.target.value)} className={inp}>
{MONTHS.map((m, i) => (
<option key={i + 1} value={i + 1}>{m}</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Year</label>
<input
type="number"
value={form.period_year}
onChange={e => set("period_year", e.target.value)}
className={inp}
placeholder="2024"
/>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Gross Pay (£)</label>
<input
type="number" step="0.01"
value={form.gross_pay}
onChange={e => set("gross_pay", e.target.value)}
className={inp}
placeholder="0.00"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Income Tax Withheld (£)</label>
<input
type="number" step="0.01"
value={form.income_tax_withheld}
onChange={e => set("income_tax_withheld", e.target.value)}
className={inp}
placeholder="0.00"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">NI Withheld (£)</label>
<input
type="number" step="0.01"
value={form.ni_withheld}
onChange={e => set("ni_withheld", e.target.value)}
className={inp}
placeholder="0.00"
/>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Net Pay (£)</label>
<input
type="number" step="0.01"
value={form.net_pay}
onChange={e => set("net_pay", e.target.value)}
className={inp}
placeholder="0.00"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Notes</label>
<input
value={form.notes}
onChange={e => set("notes", e.target.value)}
className={inp}
placeholder="Optional"
/>
</div>
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors">
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{isEdit ? "Save Changes" : "Add Payslip"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -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<Payslip | null>(null);
const [showP60, setShowP60] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(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<PayslipCreate> }) => 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 (
<>
<div className="rounded-lg border border-border bg-card">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-base font-semibold">Payslips</h2>
<div className="flex gap-2">
<button
onClick={() => setShowP60(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-md hover:bg-secondary transition-colors"
>
<FileText className="w-4 h-4" />
Enter P60
</button>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Add Payslip
</button>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading
</div>
) : payslips.length === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground">
No payslips yet add a payslip or enter P60 totals.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs uppercase tracking-wide">
<th className="px-6 py-3 text-left">Period</th>
<th className="px-4 py-3 text-right">Gross</th>
<th className="px-4 py-3 text-right">Income Tax</th>
<th className="px-4 py-3 text-right">NI</th>
<th className="px-4 py-3 text-right">Net Pay</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{payslips.map(p => (
<tr key={p.id} className="hover:bg-secondary/30 transition-colors">
<td className="px-6 py-3 font-medium">
{monthLabel(p)}
{p.is_p60 && (
<span className="ml-2 text-xs bg-primary/15 text-primary px-1.5 py-0.5 rounded">P60</span>
)}
</td>
<td className="px-4 py-3 text-right tabular-nums">{gbp(p.gross_pay)}</td>
<td className="px-4 py-3 text-right tabular-nums">{gbp(p.income_tax_withheld)}</td>
<td className="px-4 py-3 text-right tabular-nums">{gbp(p.ni_withheld)}</td>
<td className="px-4 py-3 text-right tabular-nums">{gbp(p.net_pay)}</td>
<td className="px-4 py-3 text-right">
{deletingId === p.id ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-muted-foreground">Delete?</span>
<button
onClick={() => deleteMut.mutate(p.id)}
disabled={deleteMut.isPending}
className="text-xs text-destructive hover:underline"
>
Yes
</button>
<button
onClick={() => setDeletingId(null)}
className="text-xs text-muted-foreground hover:underline"
>
No
</button>
</div>
) : (
<div className="flex items-center justify-end gap-2">
{!p.is_p60 && (
<button
onClick={() => setEditing(p)}
className="text-muted-foreground hover:text-foreground transition-colors"
title="Edit"
>
<Pencil className="w-4 h-4" />
</button>
)}
<button
onClick={() => setDeletingId(p.id)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
{payslips.length > 1 && (
<tfoot>
<tr className="border-t-2 border-border font-semibold text-xs">
<td className="px-6 py-3 text-muted-foreground uppercase tracking-wide">Total</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(totals.gross)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(totals.tax)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(totals.ni)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(totals.net)}</td>
<td />
</tr>
</tfoot>
)}
</table>
</div>
)}
</div>
{showAdd && (
<PayslipFormModal
taxYear={taxYear}
onClose={() => setShowAdd(false)}
onSubmit={data => createMut.mutate(data)}
isLoading={createMut.isPending}
/>
)}
{editing && (
<PayslipFormModal
taxYear={taxYear}
payslip={editing}
onClose={() => setEditing(null)}
onSubmit={data => updateMut.mutate({ id: editing.id, data })}
isLoading={updateMut.isPending}
/>
)}
{showP60 && (
<P60Modal
existingCount={payslips.length}
onClose={() => setShowP60(false)}
onSubmit={data => p60Mut.mutate(data)}
isLoading={p60Mut.isPending}
/>
)}
</>
);
}

View file

@ -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 (
<div className="space-y-2">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground text-xs border-b border-border">
<th className="text-left pb-2 font-normal pr-2">From (£)</th>
<th className="text-left pb-2 font-normal pr-2">To (£, blank = unlimited)</th>
<th className="text-left pb-2 font-normal pr-2">Rate (%)</th>
<th className="w-8" />
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{bands.map((b, i) => (
<tr key={i}>
<td className="py-1.5 pr-2">
<input
type="number"
value={b.from}
onChange={e => update(i, "from", e.target.value)}
className={inp}
placeholder="0"
/>
</td>
<td className="py-1.5 pr-2">
<input
type="number"
value={b.to}
onChange={e => update(i, "to", e.target.value)}
className={inp}
placeholder="—"
/>
</td>
<td className="py-1.5 pr-2">
<input
type="number"
step="0.01"
value={b.rate}
onChange={e => update(i, "rate", e.target.value)}
className={inp}
placeholder="0"
/>
</td>
<td className="py-1.5 text-right">
<button
type="button"
onClick={() => removeRow(i)}
className="text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
onClick={addRow}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<Plus className="w-3.5 h-3.5" />
Add band
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<Tab>("income_tax");
const [itBands, setItBands] = useState<BandRow[]>([]);
const [niBands, setNiBands] = useState<BandRow[]>([]);
const [cgt, setCgt] = useState<CgtForm>({ exempt: "", basic_rate: "", higher_rate: "" });
const [div, setDiv] = useState<DivForm>({
allowance: "",
basic_rate: "",
higher_rate: "",
additional_rate: "",
});
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-2xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<h2 className="text-lg font-semibold">
Rate Configuration {taxYearDisplay(taxYear)}
</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-border shrink-0 px-6">
{TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={[
"px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
tab === t.key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
{t.label}
</button>
))}
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
Loading
</div>
) : (
<>
{tab === "income_tax" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Bands are applied to gross income. The first band (0%) covers the personal
allowance; its upper limit is overridden by the tax code.
</p>
<BandTable bands={itBands} onChange={setItBands} />
</div>
)}
{tab === "ni" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Primary Class 1 NI bands. Rates are applied to gross earnings.
</p>
<BandTable bands={niBands} onChange={setNiBands} />
</div>
)}
{tab === "cgt" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium block mb-1.5">Annual Exempt Amount (£)</label>
<input
type="number"
step="1"
value={cgt.exempt}
onChange={e => setCgt(c => ({ ...c, exempt: e.target.value }))}
className={inp}
placeholder="3000"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium block mb-1.5">Basic Rate (%)</label>
<input
type="number"
step="0.01"
value={cgt.basic_rate}
onChange={e => setCgt(c => ({ ...c, basic_rate: e.target.value }))}
className={inp}
placeholder="18"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Higher Rate (%)</label>
<input
type="number"
step="0.01"
value={cgt.higher_rate}
onChange={e => setCgt(c => ({ ...c, higher_rate: e.target.value }))}
className={inp}
placeholder="24"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Basic rate applies when remaining basic-rate band &gt; 0; higher rate applies to gains above.
</p>
</div>
)}
{tab === "dividend" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium block mb-1.5">Annual Dividend Allowance (£)</label>
<input
type="number"
step="1"
value={div.allowance}
onChange={e => setDiv(d => ({ ...d, allowance: e.target.value }))}
className={inp}
placeholder="500"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium block mb-1.5">Basic Rate (%)</label>
<input
type="number"
step="0.01"
value={div.basic_rate}
onChange={e => setDiv(d => ({ ...d, basic_rate: e.target.value }))}
className={inp}
placeholder="8.75"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Higher Rate (%)</label>
<input
type="number"
step="0.01"
value={div.higher_rate}
onChange={e => setDiv(d => ({ ...d, higher_rate: e.target.value }))}
className={inp}
placeholder="33.75"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Additional Rate (%)</label>
<input
type="number"
step="0.01"
value={div.additional_rate}
onChange={e => setDiv(d => ({ ...d, additional_rate: e.target.value }))}
className={inp}
placeholder="39.35"
/>
</div>
</div>
</div>
)}
</>
)}
</div>
{/* Footer */}
{error && (
<p className="px-6 text-sm text-destructive bg-destructive/10 py-2 border-t border-border shrink-0">
{error}
</p>
)}
<div className="flex gap-3 px-6 py-4 border-t border-border shrink-0">
<button
type="button"
onClick={onClose}
className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={saveMut.isPending || isLoading}
className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saveMut.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Save Changes
</button>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="flex justify-between items-baseline mt-1 pt-2 border-t border-border">
<span className="text-xs text-muted-foreground">
{n < 0 || (n >= 0 && !isZero && overpaid) ? "Overpaid" : n === 0 ? "Balanced" : "Still owed"}
</span>
<span className={`text-sm font-semibold tabular-nums ${color}`}>{gbp(owed)}</span>
</div>
);
}
function Kv({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<div className="flex justify-between items-baseline">
<span className="text-xs text-muted-foreground">{label}</span>
<span className={`text-sm tabular-nums ${bold ? "font-semibold text-foreground" : "text-foreground"}`}>{value}</span>
</div>
);
}
function BandTable({ bands }: { bands: BandBreakdown[] }) {
if (bands.length === 0) return <p className="text-xs text-muted-foreground mt-2">No taxable amount in this category.</p>;
return (
<table className="w-full text-xs mt-2">
<thead>
<tr className="text-muted-foreground border-b border-border">
<th className="text-left pb-1 font-normal">Rate</th>
<th className="text-right pb-1 font-normal">Taxable</th>
<th className="text-right pb-1 font-normal">Tax</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{bands.map((b, i) => (
<tr key={i}>
<td className="py-1 text-muted-foreground">{pct(b.rate)}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.taxable))}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.tax))}</td>
</tr>
))}
</tbody>
</table>
);
}
interface Props {
report: TaxReport;
}
export default function TaxNISummaryCard({ report }: Props) {
const it = report.income_tax;
const ni = report.ni;
return (
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-base font-semibold mb-4">Income Tax & National Insurance</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Income Tax */}
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Income Tax</p>
<Kv label="Gross income" value={gbp(report.income.gross_income)} />
<Kv label="Personal allowance" value={`${gbp(it.personal_allowance)}`} />
<Kv label="Taxable income" value={gbp(it.taxable_income)} />
<Kv label="Liability" value={gbp(it.liability)} bold />
<Kv label="Withheld (PAYE)" value={gbp(it.withheld)} />
<OwedRow owed={it.owed} />
<BandTable bands={it.band_breakdown} />
</div>
{/* NI */}
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">National Insurance</p>
<Kv label="Liability" value={gbp(ni.liability)} bold />
<Kv label="Withheld (PAYE)" value={gbp(ni.withheld)} />
<OwedRow owed={ni.owed} />
<BandTable bands={ni.band_breakdown} />
</div>
</div>
</div>
);
}

View file

@ -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 <div className={`${h} ${w} rounded bg-secondary/60 animate-pulse`} />;
}
function ReportSkeleton() {
return (
<div className="space-y-6">
{/* at-a-glance */}
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
<SkeletonBlock h="h-5" w="w-36" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<SkeletonBlock h="h-3" w="w-20" />
<SkeletonBlock h="h-5" w="w-24" />
</div>
))}
</div>
</div>
{/* profile + payslips */}
<div className="rounded-lg border border-border bg-card p-6 space-y-3">
<SkeletonBlock h="h-5" w="w-28" />
<div className="grid grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, i) => <SkeletonBlock key={i} h="h-8" />)}
</div>
</div>
<div className="rounded-lg border border-border bg-card p-6 space-y-3">
<SkeletonBlock h="h-5" w="w-20" />
<SkeletonBlock h="h-32" />
</div>
{/* income tax + NI */}
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
<SkeletonBlock h="h-5" w="w-52" />
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">{Array.from({ length: 5 }).map((_, i) => <SkeletonBlock key={i} h="h-4" />)}</div>
<div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} h="h-4" />)}</div>
</div>
</div>
{/* CGT + dividends */}
{[48, 36].map(h => (
<div key={h} className="rounded-lg border border-border bg-card p-6 space-y-3">
<SkeletonBlock h="h-5" w="w-40" />
<SkeletonBlock h={`h-${h}`} />
</div>
))}
{/* overall */}
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
<SkeletonBlock h="h-5" w="w-64" />
<div className="grid grid-cols-2 gap-4">
<SkeletonBlock h="h-20" />
<SkeletonBlock h="h-20" />
<SkeletonBlock h="h-24" w="col-span-2 w-full" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-base font-semibold mb-4">
{taxYearDisplay(report.tax_year)} at a glance
</h2>
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4">
{items.map(({ label, value, bold, highlight, overpaid }) => (
<div key={label}>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd
className={[
"text-sm font-medium mt-0.5",
bold ? "text-foreground font-semibold" : "",
highlight && overpaid ? "text-green-500" : "",
highlight && !overpaid ? "text-yellow-500" : "",
]
.filter(Boolean)
.join(" ")}
>
{value}
</dd>
</div>
))}
</dl>
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function TaxPage() {
const { data: years = [], isLoading: yearsLoading } = useQuery({
queryKey: TAX_QUERY_KEYS.configuredYears,
queryFn: getConfiguredYears,
});
const [taxYear, setTaxYear] = useState<number | null>(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 (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading tax data
</div>
);
}
if (years.length === 0) {
return (
<div className="max-w-5xl mx-auto p-6">
<div className="rounded-lg border border-border bg-card p-8 text-center">
<p className="text-muted-foreground">No tax rate configuration found.</p>
<p className="text-sm text-muted-foreground mt-1">
Run the database migration to seed default rates.
</p>
</div>
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Tax</h1>
<div className="flex items-center gap-2">
<button
onClick={() => setShowRates(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-md hover:bg-secondary transition-colors"
title="Edit tax rates"
>
<SlidersHorizontal className="w-4 h-4" />
Edit Rates
</button>
<TaxYearSelector years={years} value={taxYear} onChange={setTaxYear} />
</div>
</div>
{/* Disclaimer */}
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>Estimates only not financial or tax advice. Always verify against HMRC.</span>
</div>
{/* Report area */}
{reportLoading && <ReportSkeleton />}
{isError && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{(error as Error)?.message ?? "Failed to load tax report."}
</div>
)}
{report && (
<>
<SummaryOverview report={report} />
<TaxProfileCard taxYear={taxYear} />
<PayslipTable taxYear={taxYear} />
<TaxNISummaryCard report={report} />
<CGTSection taxYear={taxYear} report={report} />
<DividendSection report={report} />
<OverallLiabilityCard report={report} />
</>
)}
{showRates && taxYear && (
<RateConfigModal taxYear={taxYear} onClose={() => setShowRates(false)} />
)}
</div>
);
}

View file

@ -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 (
<div className="rounded-lg border border-border bg-card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold">Tax Profile</h2>
{!editing && (
<button
onClick={openEdit}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{profile || is404 ? "Edit" : "Set up"}
</button>
)}
</div>
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Loading
</div>
)}
{!isLoading && !editing && (
<>
{isError && !is404 && (
<p className="text-sm text-destructive">Failed to load profile.</p>
)}
{(is404 || !profile) && (
<p className="text-sm text-muted-foreground">
No profile for this tax year.{" "}
<button onClick={openEdit} className="text-primary underline underline-offset-2">
Set one up
</button>{" "}
to improve estimate accuracy.
</p>
)}
{profile && (
<dl className="grid grid-cols-3 gap-6">
<div>
<dt className="text-xs text-muted-foreground mb-0.5">Tax Code</dt>
<dd className="text-sm font-medium font-mono">{profile.tax_code}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground mb-0.5">Employer</dt>
<dd className="text-sm font-medium">{profile.employer_name ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground mb-0.5">Mode</dt>
<dd className="text-sm font-medium">{profile.is_cumulative ? "Cumulative" : "Week 1 / Month 1"}</dd>
</div>
</dl>
)}
</>
)}
{editing && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground block mb-1">Tax Code</label>
<input
value={form.tax_code}
onChange={e => setForm(f => ({ ...f, tax_code: e.target.value }))}
className={`${inp} w-full font-mono`}
placeholder="1257L"
/>
</div>
<div>
<label className="text-xs text-muted-foreground block mb-1">Employer Name</label>
<input
value={form.employer_name}
onChange={e => setForm(f => ({ ...f, employer_name: e.target.value }))}
className={`${inp} w-full`}
placeholder="Optional"
/>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={form.is_cumulative}
onChange={e => setForm(f => ({ ...f, is_cumulative: e.target.checked }))}
className="rounded"
/>
Cumulative (normal PAYE; uncheck for Week 1/Month 1)
</label>
<div className="flex items-center gap-2 pt-1">
<button
onClick={handleSave}
disabled={upsertMut.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{upsertMut.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
Save
</button>
<button
onClick={() => setEditing(false)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-md hover:bg-secondary transition-colors"
>
<X className="w-3.5 h-3.5" />
Cancel
</button>
</div>
{upsertMut.isError && (
<p className="text-sm text-destructive">{(upsertMut.error as Error)?.message ?? "Failed to save"}</p>
)}
</div>
)}
</div>
);
}

View file

@ -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 (
<select
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="bg-card border border-border rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
{years.map((y) => (
<option key={y} value={y}>
{taxYearDisplay(y)}
</option>
))}
</select>
);
}

View file

@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns"; import { format } from "date-fns";
import { import {
X, Paperclip, Upload, Trash2, FileText, ImageIcon, Loader2, X, Paperclip, Upload, Trash2, FileText, ImageIcon, Loader2,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Sparkles, CheckCircle, Repeat, ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Sparkles, CheckCircle,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { formatCurrency } from "@/utils/currency"; 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 [parseResult, setParseResult] = useState<{ attId: string; data: ParsedReceipt } | null>(null);
const [parseError, setParseError] = useState<string | null>(null); const [parseError, setParseError] = useState<string | null>(null);
const [applySuccess, setApplySuccess] = useState(false); const [applySuccess, setApplySuccess] = useState(false);
const [isRecurring, setIsRecurring] = useState(transaction.is_recurring);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const Icon = TYPE_ICONS[transaction.type] ?? ArrowDownCircle; 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) => { const handleFiles = useCallback((files: FileList | null) => {
if (!files) return; if (!files) return;
setUploadError(null); setUploadError(null);
@ -209,32 +196,6 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
)} )}
</div> </div>
{/* Recurring toggle */}
<div className="flex items-center justify-between py-3 px-4 bg-secondary/40 rounded-xl">
<div className="flex items-center gap-2">
<Repeat className="w-4 h-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Recurring payment</p>
<p className="text-xs text-muted-foreground">
{isRecurring ? "Appears in Subscriptions" : "Not marked as recurring"}
</p>
</div>
</div>
<button
onClick={() => recurringMutation.mutate(!isRecurring)}
disabled={recurringMutation.isPending}
className={cn(
"relative w-11 h-6 rounded-full transition-colors focus:outline-none disabled:opacity-50",
isRecurring ? "bg-primary" : "bg-muted"
)}
>
<span className={cn(
"absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform",
isRecurring ? "translate-x-5" : "translate-x-0"
)} />
</button>
</div>
{/* Attachments */} {/* Attachments */}
<div> <div>
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">

View file

@ -2,7 +2,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { format } from "date-fns"; 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"; import type { Account } from "@/api/accounts";
const schema = z.object({ const schema = z.object({
@ -40,11 +40,9 @@ interface Props {
initialValues?: TransactionInitialValues; initialValues?: TransactionInitialValues;
parsedFromReceipt?: boolean; parsedFromReceipt?: boolean;
showAiDebug?: 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<Form>({ const { register, handleSubmit, watch, formState: { errors } } = useForm<Form>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@ -91,40 +89,14 @@ export default function TransactionFormModal({ accounts, categories, onClose, on
{parsedFromReceipt && !showAiDebug && ( {parsedFromReceipt && !showAiDebug && (
<div className="flex items-center gap-2 bg-primary/10 border border-primary/20 rounded-lg px-3 py-2 text-xs text-primary"> <div className="flex items-center gap-2 bg-primary/10 border border-primary/20 rounded-lg px-3 py-2 text-xs text-primary">
<Sparkles className="w-3.5 h-3.5 shrink-0" /> <Sparkles className="w-3.5 h-3.5 shrink-0" />
<span className="flex-1">Fields pre-filled from receipt review before saving</span> Fields pre-filled from receipt review before saving
{onRescan && (
<button
type="button"
onClick={onRescan}
disabled={rescanLoading || isLoading}
className="flex items-center gap-1 text-primary/70 hover:text-primary disabled:opacity-40 transition-colors shrink-0"
title="Rescan the same receipt"
>
{rescanLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RotateCcw className="w-3.5 h-3.5" />}
Rescan
</button>
)}
</div> </div>
)} )}
{parsedFromReceipt && showAiDebug && ( {parsedFromReceipt && showAiDebug && (
<div className="bg-primary/5 border border-primary/20 rounded-lg p-3 space-y-2"> <div className="bg-primary/5 border border-primary/20 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-primary flex items-center gap-1.5"> <p className="text-xs font-semibold text-primary flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5" /> AI scan result review before saving <Sparkles className="w-3.5 h-3.5" /> AI scan result review before saving
</p> </p>
{onRescan && (
<button
type="button"
onClick={onRescan}
disabled={rescanLoading || isLoading}
className="flex items-center gap-1 text-xs text-primary/70 hover:text-primary disabled:opacity-40 transition-colors"
title="Rescan the same receipt"
>
{rescanLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RotateCcw className="w-3.5 h-3.5" />}
Rescan
</button>
)}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs"> <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<span className="text-muted-foreground">Merchant</span><span className="font-medium truncate">{initialValues?.merchant ?? <span className="text-destructive/70 italic">not detected</span>}</span> <span className="text-muted-foreground">Merchant</span><span className="font-medium truncate">{initialValues?.merchant ?? <span className="text-destructive/70 italic">not detected</span>}</span>
<span className="text-muted-foreground">Amount</span><span className="font-medium">{initialValues?.amount != null ? initialValues.amount : <span className="text-destructive/70 italic">not detected</span>}</span> <span className="text-muted-foreground">Amount</span><span className="font-medium">{initialValues?.amount != null ? initialValues.amount : <span className="text-destructive/70 italic">not detected</span>}</span>

View file

@ -10,7 +10,7 @@ import { cn } from "@/utils/cn";
import { format, startOfMonth, subMonths, startOfYear } from "date-fns"; import { format, startOfMonth, subMonths, startOfYear } from "date-fns";
import { import {
Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload, 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"; } from "lucide-react";
import TransactionFormModal from "./TransactionFormModal"; import TransactionFormModal from "./TransactionFormModal";
import TransactionDetailDrawer from "./TransactionDetailDrawer"; import TransactionDetailDrawer from "./TransactionDetailDrawer";
@ -55,8 +55,6 @@ export default function TransactionList() {
const receiptFileRef = useRef<File | null>(null); const receiptFileRef = useRef<File | null>(null);
const [scanError, setScanError] = useState<string | null>(null); const [scanError, setScanError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [rescanLoading, setRescanLoading] = useState(false);
const [rescanKey, setRescanKey] = useState(0);
const receiptInputRef = useRef<HTMLInputElement>(null); const receiptInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filterType, setFilterType] = 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) { async function handleReceiptFile(file: File) {
setScanning(true); setScanning(true);
setScanError(null); setScanError(null);
@ -326,11 +303,6 @@ export default function TransactionList() {
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<p className="truncate font-medium">{txn.description}</p> <p className="truncate font-medium">{txn.description}</p>
{txn.is_recurring && (
<span title="Recurring payment">
<Repeat className="w-3 h-3 text-primary/60 shrink-0" />
</span>
)}
{txn.attachment_refs?.length > 0 && ( {txn.attachment_refs?.length > 0 && (
<Paperclip className="w-3 h-3 text-muted-foreground shrink-0" /> <Paperclip className="w-3 h-3 text-muted-foreground shrink-0" />
)} )}
@ -396,7 +368,6 @@ export default function TransactionList() {
{showForm && ( {showForm && (
<TransactionFormModal <TransactionFormModal
key={rescanKey}
accounts={accounts} accounts={accounts}
categories={categories} categories={categories}
onClose={() => { setShowForm(false); setReceiptParsed(null); receiptFileRef.current = null; }} onClose={() => { setShowForm(false); setReceiptParsed(null); receiptFileRef.current = null; }}
@ -413,8 +384,6 @@ export default function TransactionList() {
} : undefined} } : undefined}
parsedFromReceipt={!!receiptParsed} parsedFromReceipt={!!receiptParsed}
showAiDebug={aiSettings?.debug ?? false} showAiDebug={aiSettings?.debug ?? false}
onRescan={handleRescan}
rescanLoading={rescanLoading}
/> />
)} )}

View file

@ -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;
}

View file

@ -36,7 +36,7 @@ const config: Config = {
foreground: "hsl(var(--card-foreground))", foreground: "hsl(var(--card-foreground))",
}, },
success: { DEFAULT: "hsl(var(--success, 142 71% 45%))" }, success: { DEFAULT: "hsl(var(--success, 142 71% 45%))" },
warning: { DEFAULT: "hsl(var(--warning, 38 92% 58%))" }, warning: { DEFAULT: "#f59e0b" },
}, },
borderRadius: { borderRadius: {
lg: "var(--radius)", lg: "var(--radius)",