Compare commits
No commits in common. "afb5e99bb2c5912471da24ef7f5afecfa5d95402" and "6111424f472534da7223da5f44cf4fe0be9c94e8" have entirely different histories.
afb5e99bb2
...
6111424f47
57 changed files with 128 additions and 6416 deletions
22
README.md
22
README.md
|
|
@ -12,7 +12,7 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w
|
||||||
- Multiple account types: Checking, Savings, Cash ISA, Stocks & Shares ISA, Credit Card, Investment, Pension, Crypto Wallet, Loan, Mortgage, and more
|
- 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)
|
||||||
|
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
# Recurring Transactions — Implementation Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Automatically detect recurring transactions (direct debits, subscriptions, standing orders) from imported and existing transaction history. Tag them, predict next payment dates, and surface them in a dedicated Subscriptions page. All auto-detected values are user-correctable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Fixed-amount recurring detection only (variable-amount bills excluded — too unreliable)
|
|
||||||
- Detection runs automatically after every CSV import and on manual trigger
|
|
||||||
- Minimum 2 occurrences to flag as recurring
|
|
||||||
- Supported frequencies: weekly, fortnightly, monthly, quarterly, yearly
|
|
||||||
- User can manually mark or unmark any transaction as recurring
|
|
||||||
- New Subscriptions page in nav + recurring indicator in transaction list
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detection Algorithm
|
|
||||||
|
|
||||||
### Description normalisation
|
|
||||||
|
|
||||||
UK bank CSV exports embed reference numbers, dates, and prefixes in descriptions that differ per occurrence but refer to the same payee. Normalise before grouping:
|
|
||||||
|
|
||||||
1. Lowercase the full string
|
|
||||||
2. Strip known UK bank prefixes: `direct debit`, `dd `, `so `, `standing order`, `bacs`, `faster payment`
|
|
||||||
3. Strip trailing reference numbers: sequences of 6+ digits at end of string
|
|
||||||
4. Strip embedded date patterns (e.g. `15apr`, `15/04`)
|
|
||||||
5. Collapse multiple spaces, trim
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
```
|
|
||||||
"DIRECT DEBIT NETFLIX 00123456" → "netflix"
|
|
||||||
"DD SPOTIFY AB 987654" → "spotify ab"
|
|
||||||
"NETFLIX.COM" → "netflix.com"
|
|
||||||
"COUNCIL TAX REF 20240415" → "council tax ref"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Grouping and interval analysis
|
|
||||||
|
|
||||||
Group all non-deleted transactions for a user by `(normalised_description, exact_amount)`.
|
|
||||||
|
|
||||||
For each group with 2+ transactions:
|
|
||||||
1. Sort by date ascending
|
|
||||||
2. Calculate day-intervals between consecutive occurrences
|
|
||||||
3. Compute average interval
|
|
||||||
4. Classify against frequency table:
|
|
||||||
|
|
||||||
| Frequency | Avg days range | Per-interval tolerance |
|
|
||||||
|---|---|---|
|
|
||||||
| Weekly | 6–8 | ±2 days |
|
|
||||||
| Fortnightly | 13–15 | ±3 days |
|
|
||||||
| Monthly | 26–35 | ±5 days |
|
|
||||||
| Quarterly | 85–95 | ±10 days |
|
|
||||||
| Yearly | 355–375 | ±15 days |
|
|
||||||
|
|
||||||
5. Check all individual intervals fall within tolerance of the average
|
|
||||||
6. If matched: tag all transactions in the group
|
|
||||||
|
|
||||||
### Confidence score
|
|
||||||
|
|
||||||
`confidence = 1 - (std_dev_of_intervals / expected_interval)`
|
|
||||||
|
|
||||||
Capped at 1.0. Stored in `recurring_rule` for potential future use (e.g. sorting by confidence).
|
|
||||||
|
|
||||||
### Next expected date
|
|
||||||
|
|
||||||
- **Weekly/fortnightly**: last_date + N days
|
|
||||||
- **Monthly**: last_date + 1 month (using `dateutil.relativedelta` — handles month-end correctly)
|
|
||||||
- **Quarterly**: last_date + 3 months
|
|
||||||
- **Yearly**: last_date + 1 year
|
|
||||||
|
|
||||||
If next_expected is in the past (missed payment or detection ran late), advance by one frequency period until it's in the future.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
No new tables required. Uses existing columns on `transactions`:
|
|
||||||
|
|
||||||
```
|
|
||||||
is_recurring: bool — already exists
|
|
||||||
recurring_rule: JSONB — already exists, was never populated
|
|
||||||
```
|
|
||||||
|
|
||||||
### `recurring_rule` JSONB shape
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"frequency": "monthly",
|
|
||||||
"typical_amount": -10.99,
|
|
||||||
"typical_day": 15,
|
|
||||||
"next_expected": "2026-05-15",
|
|
||||||
"confidence": 0.94,
|
|
||||||
"detected_at": "2026-04-23T10:00:00Z",
|
|
||||||
"manually_set": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`manually_set: true` when the user explicitly toggled it — detection will not overwrite manually-set entries on subsequent re-scans.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend
|
|
||||||
|
|
||||||
### Service: `backend/app/services/recurring_service.py` (new)
|
|
||||||
|
|
||||||
```python
|
|
||||||
def normalise_description(raw: str) -> str
|
|
||||||
# Strips prefixes, refs, dates, lowercases
|
|
||||||
|
|
||||||
def classify_frequency(avg_days: float) -> str | None
|
|
||||||
# Returns "weekly" | "fortnightly" | "monthly" | "quarterly" | "yearly" | None
|
|
||||||
|
|
||||||
def next_expected_date(last_date: date, frequency: str) -> date
|
|
||||||
# Advances last_date by one frequency period; repeats until future date
|
|
||||||
|
|
||||||
async def detect_recurring(db: AsyncSession, user_id: uuid.UUID) -> dict
|
|
||||||
# Loads all transactions, runs grouping + interval analysis
|
|
||||||
# Updates is_recurring and recurring_rule on matched transactions
|
|
||||||
# Skips transactions where recurring_rule.manually_set == true
|
|
||||||
# Returns {"newly_tagged": int, "total_recurring": int}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changes to `transaction_service.py`
|
|
||||||
|
|
||||||
- `import_csv()`: call `detect_recurring(db, user_id)` after flush, before return
|
|
||||||
- `_to_response()`: ensure `recurring_rule` is included in the response dict (it currently isn't)
|
|
||||||
|
|
||||||
### New endpoint: `POST /transactions/detect-recurring`
|
|
||||||
|
|
||||||
Manual trigger. Calls `detect_recurring()` and returns the counts. Rate-limited — no need to spam it.
|
|
||||||
|
|
||||||
### Changes to `GET /transactions` (existing)
|
|
||||||
|
|
||||||
No changes needed. `is_recurring` filter already works.
|
|
||||||
|
|
||||||
### New endpoint: `GET /subscriptions`
|
|
||||||
|
|
||||||
Returns a grouped summary for the Subscriptions page:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"total_monthly_equivalent": 87.43,
|
|
||||||
"currency": "GBP",
|
|
||||||
"subscriptions": [
|
|
||||||
{
|
|
||||||
"name": "Netflix",
|
|
||||||
"amount": -10.99,
|
|
||||||
"frequency": "monthly",
|
|
||||||
"next_expected": "2026-05-03",
|
|
||||||
"last_paid": "2026-04-03",
|
|
||||||
"account_name": "Monzo",
|
|
||||||
"account_id": "...",
|
|
||||||
"transaction_ids": ["...", "..."],
|
|
||||||
"confidence": 0.98,
|
|
||||||
"manually_set": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Monthly equivalent conversion:
|
|
||||||
- Weekly × 52 / 12
|
|
||||||
- Fortnightly × 26 / 12
|
|
||||||
- Monthly × 1
|
|
||||||
- Quarterly / 3
|
|
||||||
- Yearly / 12
|
|
||||||
|
|
||||||
### Changes to `PUT /transactions/{id}` (existing)
|
|
||||||
|
|
||||||
Already accepts `is_recurring`. Extend to also accept `recurring_rule` patch so the frontend can write `manually_set: true` when the user toggles.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
### Transaction list — recurring indicator
|
|
||||||
|
|
||||||
In `TransactionList.tsx`, next to the existing paperclip icon for attachments, add a `↻` (`RefreshCw`) icon for transactions where `is_recurring === true`. Small, muted, same style as the paperclip.
|
|
||||||
|
|
||||||
### Transaction detail drawer — manual toggle
|
|
||||||
|
|
||||||
In `TransactionDetailDrawer.tsx`, add a toggle in the detail panel:
|
|
||||||
|
|
||||||
```
|
|
||||||
[ ↻ ] Mark as recurring [toggle]
|
|
||||||
```
|
|
||||||
|
|
||||||
When toggled on manually: sets `is_recurring=true`, writes `recurring_rule: { manually_set: true, frequency: null, ... }` — opens a small inline form to let the user pick the frequency and confirm the amount. When toggled off: sets `is_recurring=false`, clears `recurring_rule`.
|
|
||||||
|
|
||||||
### New page: `frontend/src/pages/subscriptions/SubscriptionsPage.tsx`
|
|
||||||
|
|
||||||
#### Header
|
|
||||||
|
|
||||||
```
|
|
||||||
Subscriptions & Standing Orders
|
|
||||||
Estimated monthly spend: £87.43 [ Re-scan ]
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-scan button calls `POST /transactions/detect-recurring`, invalidates the subscriptions query.
|
|
||||||
|
|
||||||
#### Subscription cards / list
|
|
||||||
|
|
||||||
Each row:
|
|
||||||
```
|
|
||||||
[ icon ] Netflix £10.99 / month
|
|
||||||
Monzo · last paid 3 Apr Next: 3 May [3 days] [ ··· ]
|
|
||||||
```
|
|
||||||
|
|
||||||
Colour coding on the "Next" badge:
|
|
||||||
- Red: overdue (next_expected in the past)
|
|
||||||
- Amber: within 7 days
|
|
||||||
- Muted: more than 7 days away
|
|
||||||
|
|
||||||
The `···` menu: "Mark as not recurring" (unsets it with `manually_set: true`).
|
|
||||||
|
|
||||||
#### Sort options
|
|
||||||
|
|
||||||
- Next payment (default)
|
|
||||||
- Amount (high to low)
|
|
||||||
- Name (A–Z)
|
|
||||||
|
|
||||||
#### Empty state
|
|
||||||
|
|
||||||
If no recurring transactions detected yet: prompt to import a CSV or use Re-scan.
|
|
||||||
|
|
||||||
### Nav addition
|
|
||||||
|
|
||||||
Add `Subscriptions` to the sidebar and mobile nav, between Transactions and Reports. Icon: `RefreshCw` or `Repeat`.
|
|
||||||
|
|
||||||
### API client: `frontend/src/api/subscriptions.ts` (new)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface Subscription { ... }
|
|
||||||
export interface SubscriptionsSummary { ... }
|
|
||||||
|
|
||||||
export const getSubscriptions = (): Promise<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`).
|
|
||||||
|
|
@ -1,415 +0,0 @@
|
||||||
# Tax Feature — Implementation Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Add a UK tax reporting section to MyMidas that lets a PAYE employee enter their tax code and payslip/P60 data, then automatically calculates their income tax, NI, capital gains, and dividend tax liabilities for a selected tax year — showing what's been withheld, what's owed, and a full breakdown report.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope (Phase 1)
|
|
||||||
|
|
||||||
- UK only, Rest of England / Wales / Northern Ireland tax bands (not Scotland)
|
|
||||||
- PAYE employment — single employer per tax year (schema supports more)
|
|
||||||
- Income data via monthly payslips or annual P60
|
|
||||||
- Capital gains auto-calculated from existing investment disposals + manual entry option
|
|
||||||
- Dividend tax auto-calculated from existing investment dividend transactions
|
|
||||||
- No student loan, no self-employment income, no pension contributions modelling
|
|
||||||
- Tax lives as a **dedicated sidebar page** (`/tax`) — not a tab inside Reports
|
|
||||||
|
|
||||||
Out of scope for Phase 1, possible Phase 2:
|
|
||||||
- Scotland rates
|
|
||||||
- Multiple employments
|
|
||||||
- Self-assessment / self-employment income
|
|
||||||
- Pension contribution relief
|
|
||||||
- Additional countries
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
### Table: `tax_rate_configs`
|
|
||||||
|
|
||||||
Stores the tax rates for each year. Pre-populated by migration; editable in-app via the Tax settings panel so future Budget changes don't require a code deployment or container rebuild.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | UUID PK | |
|
|
||||||
| `user_id` | UUID FK → users | RLS keyed — each user owns their own copy |
|
|
||||||
| `tax_year` | INTEGER | e.g. `2025` = year ending 5 April 2025 |
|
|
||||||
| `rate_type` | VARCHAR(30) | `income_tax` / `ni` / `cgt` / `dividend` |
|
|
||||||
| `config` | JSONB | Rate bands / thresholds for that type (see format below) |
|
|
||||||
| `updated_at` | TIMESTAMPTZ | |
|
|
||||||
|
|
||||||
Unique constraint: `(user_id, tax_year, rate_type)`.
|
|
||||||
|
|
||||||
Migration pre-populates rows for **2025** and **2026** for every new user (see seed data below). The service loads rates from this table (with a short in-process TTL cache) rather than from hardcoded Python constants.
|
|
||||||
|
|
||||||
#### Config JSONB format per rate_type
|
|
||||||
|
|
||||||
**income_tax** and **ni**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bands": [
|
|
||||||
{"from": 0, "to": 12570, "rate": 0.00},
|
|
||||||
{"from": 12571, "to": 50270, "rate": 0.20},
|
|
||||||
{"from": 50271, "to": 125140, "rate": 0.40},
|
|
||||||
{"from": 125141, "to": null, "rate": 0.45}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**cgt**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"exempt": 3000,
|
|
||||||
"basic_rate": 0.18,
|
|
||||||
"higher_rate": 0.24
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**dividend**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"allowance": 500,
|
|
||||||
"basic_rate": 0.0875,
|
|
||||||
"higher_rate": 0.3375,
|
|
||||||
"additional_rate": 0.3935
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Seed data (2025 and 2026)
|
|
||||||
|
|
||||||
2025 = year ending 5 April 2025 (2024/25). CGT rates reflect the October 2024 Budget change (18%/24% effective 30 Oct 2024 — applied to the full year for simplicity; note in disclaimer).
|
|
||||||
|
|
||||||
2026 = year ending 5 April 2026 (2025/26). Income tax thresholds remain frozen; NI, CGT, and dividend rates unchanged from 2025.
|
|
||||||
|
|
||||||
```python
|
|
||||||
SEED_RATE_CONFIGS = {
|
|
||||||
2025: {
|
|
||||||
"income_tax": {"bands": [
|
|
||||||
{"from": 0, "to": 12570, "rate": 0.00},
|
|
||||||
{"from": 12571, "to": 50270, "rate": 0.20},
|
|
||||||
{"from": 50271, "to": 125140, "rate": 0.40},
|
|
||||||
{"from": 125141, "to": None, "rate": 0.45},
|
|
||||||
]},
|
|
||||||
"ni": {"bands": [
|
|
||||||
{"from": 0, "to": 12570, "rate": 0.00},
|
|
||||||
{"from": 12571, "to": 50270, "rate": 0.08},
|
|
||||||
{"from": 50271, "to": None, "rate": 0.02},
|
|
||||||
]},
|
|
||||||
"cgt": {"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24},
|
|
||||||
"dividend": {"allowance": 500, "basic_rate": 0.0875,
|
|
||||||
"higher_rate": 0.3375, "additional_rate": 0.3935},
|
|
||||||
},
|
|
||||||
2026: {
|
|
||||||
"income_tax": {"bands": [
|
|
||||||
{"from": 0, "to": 12570, "rate": 0.00},
|
|
||||||
{"from": 12571, "to": 50270, "rate": 0.20},
|
|
||||||
{"from": 50271, "to": 125140, "rate": 0.40},
|
|
||||||
{"from": 125141, "to": None, "rate": 0.45},
|
|
||||||
]},
|
|
||||||
"ni": {"bands": [
|
|
||||||
{"from": 0, "to": 12570, "rate": 0.00},
|
|
||||||
{"from": 12571, "to": 50270, "rate": 0.08},
|
|
||||||
{"from": 50271, "to": None, "rate": 0.02},
|
|
||||||
]},
|
|
||||||
"cgt": {"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24},
|
|
||||||
"dividend": {"allowance": 500, "basic_rate": 0.0875,
|
|
||||||
"higher_rate": 0.3375, "additional_rate": 0.3935},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When a new user registers, the `tax_rate_configs` rows for 2025 and 2026 are inserted automatically (same place other user-default data is seeded). Adding a future year (e.g. 2027) requires inserting new rows — a small migration — but never a code change.
|
|
||||||
|
|
||||||
### Table: `tax_profiles`
|
|
||||||
|
|
||||||
One row per tax year. Designed to support multiple employments in future.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | UUID PK | |
|
|
||||||
| `user_id` | UUID FK → users | RLS keyed |
|
|
||||||
| `tax_year` | INTEGER | e.g. `2025` = year ending 5 April 2025 |
|
|
||||||
| `employer_name_enc` | BYTEA | AES-256-GCM encrypted |
|
|
||||||
| `tax_code` | VARCHAR(20) | e.g. `1257L`, `BR`, `D0`, `K100` |
|
|
||||||
| `is_cumulative` | BOOLEAN | `true` = cumulative basis, `false` = W1/M1 |
|
|
||||||
| `created_at` | TIMESTAMPTZ | |
|
|
||||||
| `updated_at` | TIMESTAMPTZ | |
|
|
||||||
|
|
||||||
Unique constraint: `(user_id, tax_year)`.
|
|
||||||
|
|
||||||
### Table: `payslips`
|
|
||||||
|
|
||||||
Monthly payslip entries, or a single P60 annual entry.
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | UUID PK | |
|
|
||||||
| `user_id` | UUID FK → users | RLS keyed |
|
|
||||||
| `tax_profile_id` | UUID FK → tax_profiles | |
|
|
||||||
| `period_month` | SMALLINT | 1–12; `NULL` if `is_p60 = true` |
|
|
||||||
| `period_year` | SMALLINT | Calendar year of the payslip |
|
|
||||||
| `gross_pay` | NUMERIC(14,2) | |
|
|
||||||
| `income_tax_withheld` | NUMERIC(14,2) | |
|
|
||||||
| `ni_withheld` | NUMERIC(14,2) | |
|
|
||||||
| `net_pay` | NUMERIC(14,2) | |
|
|
||||||
| `is_p60` | BOOLEAN | `true` = this is the annual P60 figure |
|
|
||||||
| `notes_enc` | BYTEA | AES-256-GCM encrypted, optional |
|
|
||||||
| `created_at` | TIMESTAMPTZ | |
|
|
||||||
|
|
||||||
When a P60 is entered for a tax year, all existing individual payslips for that profile are deleted and replaced by the single P60 row (confirmed via a warning dialog in the UI).
|
|
||||||
|
|
||||||
### Table: `manual_cgt_disposals`
|
|
||||||
|
|
||||||
For assets not tracked in the investments section (e.g. property, share schemes, other).
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | UUID PK | |
|
|
||||||
| `user_id` | UUID FK → users | RLS keyed |
|
|
||||||
| `tax_year` | INTEGER | |
|
|
||||||
| `disposal_date` | DATE | |
|
|
||||||
| `asset_description_enc` | BYTEA | AES-256-GCM encrypted |
|
|
||||||
| `proceeds` | NUMERIC(14,2) | |
|
|
||||||
| `cost_basis` | NUMERIC(14,2) | |
|
|
||||||
| `notes_enc` | BYTEA | AES-256-GCM encrypted |
|
|
||||||
| `created_at` | TIMESTAMPTZ | |
|
|
||||||
|
|
||||||
`gain_loss` is **not stored** — computed in the service as `proceeds − cost_basis`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tax Calculation Engine
|
|
||||||
|
|
||||||
### File: `backend/app/services/tax_service.py`
|
|
||||||
|
|
||||||
Rates are loaded from `tax_rate_configs` (DB), not hardcoded. The service caches the loaded config per `(user_id, tax_year)` for the lifetime of the request.
|
|
||||||
|
|
||||||
#### Tax year helper
|
|
||||||
|
|
||||||
UK tax year runs 6 April → 5 April. Convention: `tax_year=2025` means the year **ending** 5 April 2025 (the 2024/25 tax year).
|
|
||||||
|
|
||||||
```python
|
|
||||||
def tax_year_for_date(d: date) -> int:
|
|
||||||
"""Return the tax_year int for a given date. tax_year=N means 6 Apr (N-1) → 5 Apr N."""
|
|
||||||
if (d.month, d.day) >= (4, 6):
|
|
||||||
return d.year + 1
|
|
||||||
return d.year
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Core functions
|
|
||||||
|
|
||||||
All calculation functions receive a `rates: dict` argument (the loaded config for that year/type) rather than reading from constants.
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def load_rates(db, user_id, tax_year) -> dict:
|
|
||||||
# Returns {"income_tax": {...}, "ni": {...}, "cgt": {...}, "dividend": {...}}
|
|
||||||
# Loads from tax_rate_configs table; raises 404 if year not configured
|
|
||||||
|
|
||||||
def parse_tax_code(code: str) -> dict:
|
|
||||||
# Returns: {"allowance": int, "rate_override": float | None, "k_code": bool}
|
|
||||||
|
|
||||||
def calculate_income_tax(gross_income: Decimal, tax_code: str, rates: dict) -> dict:
|
|
||||||
# Returns: {"personal_allowance": Decimal, "taxable_income": Decimal,
|
|
||||||
# "liability": Decimal, "band_breakdown": [...]}
|
|
||||||
|
|
||||||
def calculate_ni(gross_income: Decimal, rates: dict) -> dict:
|
|
||||||
# Returns: {"liability": Decimal, "band_breakdown": [...]}
|
|
||||||
|
|
||||||
def calculate_cgt(gains: Decimal, gross_income: Decimal, rates: dict) -> dict:
|
|
||||||
# Determines basic vs higher rate from remaining basic rate band
|
|
||||||
# Returns: {"gross_gain": Decimal, "exempt": Decimal, "taxable_gain": Decimal,
|
|
||||||
# "rate_applied": float, "liability": Decimal}
|
|
||||||
|
|
||||||
def calculate_dividend_tax(dividends: Decimal, gross_income: Decimal, rates: dict) -> dict:
|
|
||||||
# Returns: {"gross_dividends": Decimal, "allowance": Decimal,
|
|
||||||
# "taxable_dividends": Decimal, "liability": Decimal, "rate_applied": float}
|
|
||||||
|
|
||||||
async def build_tax_report(db, user_id, tax_year) -> dict:
|
|
||||||
# Loads rates, pulls payslip totals, investment disposals, dividend transactions
|
|
||||||
# Returns the full report payload
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Tax code parser
|
|
||||||
|
|
||||||
| Code pattern | Behaviour |
|
|
||||||
|---|---|
|
|
||||||
| `1257L`, `1257M`, `1257N` | allowance = digits × 10 |
|
|
||||||
| `BR` | allowance = 0, flat 20% on all income |
|
|
||||||
| `D0` | flat 40% on all income |
|
|
||||||
| `D1` | flat 45% on all income |
|
|
||||||
| `NT` | no tax |
|
|
||||||
| `K100` | negative allowance: taxable income += digits × 10 |
|
|
||||||
| `0T` | allowance = 0, standard bands apply |
|
|
||||||
| `W1`/`M1` suffix | non-cumulative (informational only for Phase 1) |
|
|
||||||
|
|
||||||
Personal allowance taper: reduce by £1 for every £2 of income above £100,000, down to zero at £125,140.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend API
|
|
||||||
|
|
||||||
### File: `backend/app/api/v1/tax.py`
|
|
||||||
|
|
||||||
All routes prefixed `/tax`.
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/tax/rate-configs` | List configured tax years for this user |
|
|
||||||
| GET | `/tax/rate-configs/{tax_year}` | Get full rate config for a year |
|
|
||||||
| PUT | `/tax/rate-configs/{tax_year}` | Create or update rate config for a year |
|
|
||||||
| GET | `/tax/profile/{tax_year}` | Get profile for a tax year (404 if none) |
|
|
||||||
| PUT | `/tax/profile/{tax_year}` | Create or update profile (tax code, employer name) |
|
|
||||||
| GET | `/tax/payslips/{tax_year}` | List payslips for a tax year |
|
|
||||||
| POST | `/tax/payslips/{tax_year}` | Add a payslip |
|
|
||||||
| PUT | `/tax/payslips/{id}` | Edit a payslip |
|
|
||||||
| DELETE | `/tax/payslips/{id}` | Delete a payslip |
|
|
||||||
| POST | `/tax/payslips/{tax_year}/p60` | Enter P60 — replaces all individual payslips |
|
|
||||||
| GET | `/tax/cgt-disposals/{tax_year}` | List manual CGT disposals |
|
|
||||||
| POST | `/tax/cgt-disposals/{tax_year}` | Add a manual disposal |
|
|
||||||
| PUT | `/tax/cgt-disposals/{id}` | Edit |
|
|
||||||
| DELETE | `/tax/cgt-disposals/{id}` | Delete |
|
|
||||||
| GET | `/tax/report/{tax_year}` | Full computed tax report for a year |
|
|
||||||
|
|
||||||
### Pydantic schemas: `backend/app/schemas/tax.py`
|
|
||||||
|
|
||||||
- `TaxRateConfigUpdate` / `TaxRateConfigResponse`
|
|
||||||
- `TaxProfileCreate` / `TaxProfileResponse`
|
|
||||||
- `PayslipCreate` / `PayslipResponse`
|
|
||||||
- `P60Entry` (gross_pay, income_tax_withheld, ni_withheld, net_pay)
|
|
||||||
- `ManualDisposalCreate` / `ManualDisposalResponse`
|
|
||||||
- `TaxReportResponse`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration
|
|
||||||
|
|
||||||
New Alembic migration: `add_tax_tables`
|
|
||||||
|
|
||||||
- Creates `tax_rate_configs`, `tax_profiles`, `payslips`, `manual_cgt_disposals`
|
|
||||||
- Adds RLS policies (same pattern as other tables: `app.current_user_id`)
|
|
||||||
- Encrypted columns stored as `_enc bytea`: `employer_name_enc`, `notes_enc`, `asset_description_enc`
|
|
||||||
- Seeds `tax_rate_configs` rows for 2025 and 2026 for all existing users inside the migration (so existing accounts get rates without re-registering)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
### Nav changes
|
|
||||||
|
|
||||||
Add `{ href: "/tax", icon: Receipt, label: "Tax" }` to both:
|
|
||||||
- `frontend/src/components/layout/Sidebar.tsx` — between Reports and Predictions
|
|
||||||
- `frontend/src/components/layout/MobileNav.tsx` — same position
|
|
||||||
- `frontend/src/App.tsx` — add `/tax` route pointing to `TaxPage`
|
|
||||||
|
|
||||||
### API client: `frontend/src/api/tax.ts`
|
|
||||||
|
|
||||||
Typed functions for all endpoints. Interfaces for:
|
|
||||||
- `TaxRateConfig`, `TaxRateConfigUpdate`
|
|
||||||
- `TaxProfile`, `TaxProfileCreate`
|
|
||||||
- `Payslip`, `PayslipCreate`, `P60Entry`
|
|
||||||
- `ManualDisposal`, `ManualDisposalCreate`
|
|
||||||
- `TaxReport`
|
|
||||||
|
|
||||||
### Page: `frontend/src/pages/tax/TaxPage.tsx`
|
|
||||||
|
|
||||||
Top-level page at `/tax`. Contains the full tax UI.
|
|
||||||
|
|
||||||
#### Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
[ Tax Year selector: 2024/25 | 2025/26 | ... ] [ Rate Config button (edit rates for year) ]
|
|
||||||
|
|
||||||
[ Tax Profile card ]
|
|
||||||
Employer: Acme Ltd Tax Code: 1257L [ Edit ]
|
|
||||||
|
|
||||||
[ Income & PAYE section ]
|
|
||||||
Payslip table (month | gross | tax withheld | NI withheld | net)
|
|
||||||
[ + Add Payslip ] [ Enter P60 ]
|
|
||||||
Summary row: totals
|
|
||||||
|
|
||||||
[ Tax & NI Summary card ]
|
|
||||||
Gross income £xx,xxx
|
|
||||||
Personal allowance £12,570
|
|
||||||
Taxable income £xx,xxx
|
|
||||||
─────────────────────────────────────────
|
|
||||||
Income tax liability £x,xxx Withheld £x,xxx [ Owed / Overpaid ]
|
|
||||||
NI liability £x,xxx Withheld £x,xxx [ Owed / Overpaid ]
|
|
||||||
|
|
||||||
[ Capital Gains section ]
|
|
||||||
Auto-detected disposals from investments (read-only table)
|
|
||||||
Manual disposals table [ + Add Disposal ]
|
|
||||||
Summary: total gains | exempt | taxable | estimated CGT
|
|
||||||
|
|
||||||
[ Dividends section ]
|
|
||||||
Auto-detected from investment dividend transactions (read-only)
|
|
||||||
Summary: total dividends | allowance | taxable | estimated dividend tax
|
|
||||||
|
|
||||||
[ Overall Liability card ]
|
|
||||||
┌──────────────────────────────────────────┐
|
|
||||||
│ Total liability £x,xxx │
|
|
||||||
│ Total withheld £x,xxx │
|
|
||||||
│ ─────────────────────────────────────── │
|
|
||||||
│ Net owed to HMRC / Overpaid £x,xxx │
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
|
|
||||||
[ Disclaimer: estimates only — not financial advice ]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Components to build (all in `frontend/src/pages/tax/`)
|
|
||||||
|
|
||||||
- `TaxPage.tsx` — top-level, holds selected tax year state
|
|
||||||
- `TaxYearSelector.tsx` — dropdown of configured years
|
|
||||||
- `RateConfigModal.tsx` — shows/edits the JSONB rate bands for the selected year (table of bands, editable inputs)
|
|
||||||
- `TaxProfileCard.tsx` — shows/edits tax code and employer name
|
|
||||||
- `PayslipTable.tsx` — list, add, edit, delete; "Enter P60" button with confirmation dialog
|
|
||||||
- `PayslipFormModal.tsx` — single payslip month form
|
|
||||||
- `P60Modal.tsx` — four-field form (gross, tax withheld, NI withheld, net) with warning dialog
|
|
||||||
- `TaxNISummaryCard.tsx` — computed liability vs withheld, owed/overpaid highlighted
|
|
||||||
- `CGTSection.tsx` — auto + manual disposal tables, summary
|
|
||||||
- `ManualDisposalFormModal.tsx`
|
|
||||||
- `DividendSection.tsx` — auto-pulled, summary only
|
|
||||||
- `OverallLiabilityCard.tsx` — final totals
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
1. **Migration** — create the four tables with RLS policies; seed 2025/2026 rate configs for existing users
|
|
||||||
2. **Tax calculation engine** (`tax_service.py`) — pure functions taking `rates: dict`, unit-testable without DB
|
|
||||||
3. **Backend models** (`db/models/tax.py`) — SQLAlchemy mapped classes
|
|
||||||
4. **Pydantic schemas** (`schemas/tax.py`)
|
|
||||||
5. **Service layer** — DB queries for CRUD + `load_rates()` + `build_tax_report()`
|
|
||||||
6. **API endpoints** (`api/v1/tax.py`) + register in `router.py`
|
|
||||||
7. **Frontend API client** (`api/tax.ts`)
|
|
||||||
8. **Nav wiring** — add Tax to Sidebar, MobileNav, App.tsx routes
|
|
||||||
9. **Tax page UI** — build components top-to-bottom following the layout above
|
|
||||||
10. **Rate config UI** — `RateConfigModal` so rates can be edited without touching code
|
|
||||||
11. **End-to-end test** — enter a full year of payslips, verify liability matches HMRC calculator
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checkpoints
|
|
||||||
|
|
||||||
- Tax code parser: `1257L`, `BR`, `D0`, `K100`, `0T`, `1257M`
|
|
||||||
- Income tax: verify bands at £12,570 / £50,270 / £100,000 (taper) / £125,140
|
|
||||||
- NI: verify thresholds
|
|
||||||
- CGT: basic rate (18%) vs higher rate (24%) taxpayer
|
|
||||||
- P60 replacement: individual payslips deleted before P60 insert
|
|
||||||
- Investment disposal auto-detection: verify `tax_year_for_date` boundary (6 Apr)
|
|
||||||
- Rate config: edit a band value, confirm report recalculates using new value
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes & Decisions
|
|
||||||
|
|
||||||
- **Tax year convention**: `tax_year = 2025` = year ending 5 April 2025 = 2024/25. Always display as "2024/25" in the UI.
|
|
||||||
- **Configurable rates (Option A)**: Rates live in `tax_rate_configs` DB table. Pre-populated for 2025 and 2026. When a new Budget changes rates, the user edits them in-app via `RateConfigModal` — no code change or rebuild needed. Adding a brand new tax year requires a small migration to insert new rows, but that's still no code change in the calculation logic.
|
|
||||||
- **Encrypted fields**: `employer_name_enc`, `notes_enc`, `asset_description_enc` — all PII stored as `_enc bytea` using `encrypt_field`/`decrypt_field` from `core/security.py`.
|
|
||||||
- **`gain_loss` not stored**: Computed in service as `proceeds − cost_basis`. Not a DB column.
|
|
||||||
- **CGT rates (post Oct 2024 Budget)**: 18% basic rate, 24% higher rate. For 2024/25 (tax_year=2025), the change was effective 30 Oct 2024 mid-year — the seeded rate uses 18%/24% for the full year with a disclaimer note in the UI.
|
|
||||||
- **CGT rate determination**: Requires knowing whether the user is basic or higher rate (remaining basic rate band after income). `build_tax_report()` computes income tax first, then passes the remaining band to `calculate_cgt()`.
|
|
||||||
- **Sidebar placement**: Tax sits between Reports and Predictions in both `Sidebar.tsx` and `MobileNav.tsx`.
|
|
||||||
- **Disclaimer**: Report UI must include a visible note that figures are estimates for informational purposes only and are not financial or tax advice.
|
|
||||||
- **Future expansion**: `tax_profiles` unique constraint is `(user_id, tax_year)` for now — relax to allow multiple rows per year when multi-employment is added.
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
"""
|
|
||||||
Pure UK tax calculation functions. Zero external dependencies — no DB, no ORM.
|
|
||||||
Each function receives a pre-loaded `rates` dict so they are fully unit-testable.
|
|
||||||
|
|
||||||
Tax year convention: tax_year=N means 6 Apr (N-1) → 5 Apr N.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import date
|
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tax year helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def tax_year_for_date(d: date) -> int:
|
|
||||||
"""Return tax_year int for a calendar date. tax_year=N = 6 Apr (N-1) → 5 Apr N."""
|
|
||||||
if (d.month, d.day) >= (4, 6):
|
|
||||||
return d.year + 1
|
|
||||||
return d.year
|
|
||||||
|
|
||||||
|
|
||||||
def tax_year_date_range(tax_year: int) -> tuple[date, date]:
|
|
||||||
"""Return (start_date, end_date) inclusive for the given tax year."""
|
|
||||||
return date(tax_year - 1, 4, 6), date(tax_year, 4, 5)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tax code parser
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def parse_tax_code(code: str) -> dict[str, Any]:
|
|
||||||
"""Parse a UK tax code string.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
allowance — annual personal allowance in £ (negative for K codes)
|
|
||||||
rate_override — flat rate (0.0–1.0) if code fixes a single rate, else None
|
|
||||||
k_code — True if K prefix (negative allowance)
|
|
||||||
no_tax — True if NT code
|
|
||||||
"""
|
|
||||||
raw = code.strip().upper()
|
|
||||||
raw = re.sub(r"[/\s]?(W1|M1)$", "", raw)
|
|
||||||
|
|
||||||
if raw == "NT":
|
|
||||||
return {"allowance": Decimal("0"), "rate_override": Decimal("0"), "k_code": False, "no_tax": True}
|
|
||||||
if raw == "BR":
|
|
||||||
return {"allowance": Decimal("0"), "rate_override": Decimal("0.20"), "k_code": False, "no_tax": False}
|
|
||||||
if raw == "D0":
|
|
||||||
return {"allowance": Decimal("0"), "rate_override": Decimal("0.40"), "k_code": False, "no_tax": False}
|
|
||||||
if raw == "D1":
|
|
||||||
return {"allowance": Decimal("0"), "rate_override": Decimal("0.45"), "k_code": False, "no_tax": False}
|
|
||||||
if raw == "0T":
|
|
||||||
return {"allowance": Decimal("0"), "rate_override": None, "k_code": False, "no_tax": False}
|
|
||||||
|
|
||||||
k_match = re.fullmatch(r"K(\d+)", raw)
|
|
||||||
if k_match:
|
|
||||||
return {"allowance": -Decimal(k_match.group(1)) * 10, "rate_override": None, "k_code": True, "no_tax": False}
|
|
||||||
|
|
||||||
std_match = re.fullmatch(r"(\d+)[LMNTY]?", raw)
|
|
||||||
if std_match:
|
|
||||||
return {"allowance": Decimal(std_match.group(1)) * 10, "rate_override": None, "k_code": False, "no_tax": False}
|
|
||||||
|
|
||||||
# Unknown code — treat as 0T
|
|
||||||
return {"allowance": Decimal("0"), "rate_override": None, "k_code": False, "no_tax": False}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _apply_bands(amount: Decimal, bands: list[dict]) -> tuple[Decimal, list[dict]]:
|
|
||||||
total = Decimal("0")
|
|
||||||
breakdown = []
|
|
||||||
for band in bands:
|
|
||||||
band_from = Decimal(str(band["from"]))
|
|
||||||
band_to = Decimal(str(band["to"])) if band["to"] is not None else None
|
|
||||||
rate = Decimal(str(band["rate"]))
|
|
||||||
|
|
||||||
if amount <= band_from:
|
|
||||||
break
|
|
||||||
|
|
||||||
upper = min(amount, band_to) if band_to is not None else amount
|
|
||||||
taxable_in_band = upper - band_from
|
|
||||||
tax_in_band = (taxable_in_band * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
||||||
total += tax_in_band
|
|
||||||
|
|
||||||
if taxable_in_band > 0:
|
|
||||||
breakdown.append({
|
|
||||||
"from": int(band_from),
|
|
||||||
"to": int(band_to) if band_to is not None else None,
|
|
||||||
"rate": float(rate),
|
|
||||||
"taxable": float(taxable_in_band),
|
|
||||||
"tax": float(tax_in_band),
|
|
||||||
})
|
|
||||||
|
|
||||||
return total, breakdown
|
|
||||||
|
|
||||||
|
|
||||||
def _personal_allowance_tapered(base_allowance: Decimal, gross_income: Decimal) -> Decimal:
|
|
||||||
"""Reduce PA by £1 per £2 over £100,000; floor at zero at £125,140."""
|
|
||||||
taper_threshold = Decimal("100000")
|
|
||||||
if gross_income <= taper_threshold:
|
|
||||||
return base_allowance
|
|
||||||
reduction = ((gross_income - taper_threshold) / 2).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
|
||||||
return max(Decimal("0"), base_allowance - reduction)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Core calculation functions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def calculate_income_tax(
|
|
||||||
gross_income: Decimal,
|
|
||||||
tax_code: str,
|
|
||||||
rates: dict,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Calculate income tax liability and remaining basic-rate band.
|
|
||||||
|
|
||||||
Bands are applied to GROSS income. The 0% band threshold is adjusted to match
|
|
||||||
the actual personal allowance from the tax code (with taper applied if applicable).
|
|
||||||
K codes add their amount to gross income before applying the standard bands.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
personal_allowance, taxable_income, liability, band_breakdown,
|
|
||||||
remaining_basic_rate_band (passed downstream to CGT/dividend calculations)
|
|
||||||
"""
|
|
||||||
parsed = parse_tax_code(tax_code)
|
|
||||||
bands = rates["income_tax"]["bands"]
|
|
||||||
# These are gross-income thresholds from the band definitions
|
|
||||||
pa_threshold = Decimal(str(next(b["to"] for b in bands if b["rate"] == 0.00)))
|
|
||||||
basic_rate_upper = Decimal(str(next(b["to"] for b in bands if b["rate"] == 0.20)))
|
|
||||||
|
|
||||||
if parsed["no_tax"]:
|
|
||||||
return {
|
|
||||||
"personal_allowance": pa_threshold,
|
|
||||||
"taxable_income": Decimal("0"),
|
|
||||||
"liability": Decimal("0"),
|
|
||||||
"band_breakdown": [],
|
|
||||||
"remaining_basic_rate_band": max(Decimal("0"), basic_rate_upper - gross_income),
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed["rate_override"] is not None:
|
|
||||||
liability = (gross_income * parsed["rate_override"]).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
||||||
return {
|
|
||||||
"personal_allowance": Decimal("0"),
|
|
||||||
"taxable_income": gross_income,
|
|
||||||
"liability": liability,
|
|
||||||
"band_breakdown": [{"rate": float(parsed["rate_override"]), "tax": float(liability)}],
|
|
||||||
"remaining_basic_rate_band": Decimal("0"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed["k_code"]:
|
|
||||||
# K codes: the K amount adds to taxable base; standard PA band still applies to effective gross.
|
|
||||||
# effective_gross is the "notional income" HMRC uses to calculate tax.
|
|
||||||
k_amount = abs(parsed["allowance"])
|
|
||||||
effective_gross = gross_income + k_amount
|
|
||||||
personal_allowance = Decimal("0") # K code replaces any standard PA grant
|
|
||||||
taxable_income = effective_gross # reported as the effective taxable base
|
|
||||||
liability, band_breakdown = _apply_bands(effective_gross, bands)
|
|
||||||
remaining_brb = max(Decimal("0"), basic_rate_upper - effective_gross)
|
|
||||||
else:
|
|
||||||
base_pa = parsed["allowance"]
|
|
||||||
personal_allowance = _personal_allowance_tapered(base_pa, gross_income) if base_pa > 0 else base_pa
|
|
||||||
|
|
||||||
# Adjust the 0% band to match the actual personal allowance, then apply to gross income.
|
|
||||||
adjusted_bands = [
|
|
||||||
{"from": 0, "to": float(personal_allowance), "rate": 0.00} if b["rate"] == 0.00 else b
|
|
||||||
for b in bands
|
|
||||||
]
|
|
||||||
taxable_income = max(Decimal("0"), gross_income - personal_allowance)
|
|
||||||
liability, band_breakdown = _apply_bands(gross_income, adjusted_bands)
|
|
||||||
remaining_brb = max(Decimal("0"), basic_rate_upper - gross_income)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"personal_allowance": personal_allowance,
|
|
||||||
"taxable_income": taxable_income,
|
|
||||||
"liability": liability,
|
|
||||||
"band_breakdown": band_breakdown,
|
|
||||||
"remaining_basic_rate_band": remaining_brb,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ni(gross_income: Decimal, rates: dict) -> dict[str, Any]:
|
|
||||||
"""Calculate primary Class 1 NI liability."""
|
|
||||||
bands = rates["ni"]["bands"]
|
|
||||||
liability, band_breakdown = _apply_bands(gross_income, bands)
|
|
||||||
return {"liability": liability, "band_breakdown": band_breakdown}
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_cgt(
|
|
||||||
total_gain: Decimal,
|
|
||||||
remaining_basic_rate_band: Decimal,
|
|
||||||
rates: dict,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Calculate CGT liability.
|
|
||||||
|
|
||||||
Gains within the remaining basic-rate band are taxed at basic_rate;
|
|
||||||
gains above it at higher_rate. Annual exempt amount applied first.
|
|
||||||
"""
|
|
||||||
cgt_rates = rates["cgt"]
|
|
||||||
exempt = Decimal(str(cgt_rates["exempt"]))
|
|
||||||
basic_rate = Decimal(str(cgt_rates["basic_rate"]))
|
|
||||||
higher_rate = Decimal(str(cgt_rates["higher_rate"]))
|
|
||||||
|
|
||||||
taxable_gain = max(Decimal("0"), total_gain - exempt)
|
|
||||||
|
|
||||||
if taxable_gain == 0:
|
|
||||||
return {
|
|
||||||
"gross_gain": total_gain,
|
|
||||||
"exempt": min(total_gain, exempt) if total_gain > 0 else Decimal("0"),
|
|
||||||
"taxable_gain": Decimal("0"),
|
|
||||||
"liability": Decimal("0"),
|
|
||||||
"band_breakdown": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
basic_portion = min(taxable_gain, remaining_basic_rate_band)
|
|
||||||
higher_portion = taxable_gain - basic_portion
|
|
||||||
|
|
||||||
liability = (
|
|
||||||
(basic_portion * basic_rate) + (higher_portion * higher_rate)
|
|
||||||
).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
||||||
|
|
||||||
breakdown = []
|
|
||||||
if basic_portion > 0:
|
|
||||||
breakdown.append({"rate": float(basic_rate), "taxable": float(basic_portion),
|
|
||||||
"tax": float((basic_portion * basic_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))})
|
|
||||||
if higher_portion > 0:
|
|
||||||
breakdown.append({"rate": float(higher_rate), "taxable": float(higher_portion),
|
|
||||||
"tax": float((higher_portion * higher_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"gross_gain": total_gain,
|
|
||||||
"exempt": min(total_gain, exempt),
|
|
||||||
"taxable_gain": taxable_gain,
|
|
||||||
"liability": liability,
|
|
||||||
"band_breakdown": breakdown,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_dividend_tax(
|
|
||||||
total_dividends: Decimal,
|
|
||||||
remaining_basic_rate_band: Decimal,
|
|
||||||
rates: dict,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Calculate dividend tax liability.
|
|
||||||
|
|
||||||
Dividend allowance applied first; taxable dividends are then slotted into
|
|
||||||
the remaining income bands to determine which rate applies.
|
|
||||||
"""
|
|
||||||
div_rates = rates["dividend"]
|
|
||||||
allowance = Decimal(str(div_rates["allowance"]))
|
|
||||||
basic_rate = Decimal(str(div_rates["basic_rate"]))
|
|
||||||
higher_rate = Decimal(str(div_rates["higher_rate"]))
|
|
||||||
additional_rate = Decimal(str(div_rates["additional_rate"]))
|
|
||||||
|
|
||||||
taxable_dividends = max(Decimal("0"), total_dividends - allowance)
|
|
||||||
|
|
||||||
if taxable_dividends == 0:
|
|
||||||
return {
|
|
||||||
"gross_dividends": total_dividends,
|
|
||||||
"allowance": min(total_dividends, allowance),
|
|
||||||
"taxable_dividends": Decimal("0"),
|
|
||||||
"liability": Decimal("0"),
|
|
||||||
"band_breakdown": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
basic_portion = min(taxable_dividends, remaining_basic_rate_band)
|
|
||||||
remainder = taxable_dividends - basic_portion
|
|
||||||
higher_upper = Decimal(str(
|
|
||||||
next(b["to"] for b in rates["income_tax"]["bands"] if b["rate"] == 0.40)
|
|
||||||
))
|
|
||||||
higher_portion = min(remainder, higher_upper)
|
|
||||||
additional_portion = remainder - higher_portion
|
|
||||||
|
|
||||||
liability = (
|
|
||||||
(basic_portion * basic_rate)
|
|
||||||
+ (higher_portion * higher_rate)
|
|
||||||
+ (additional_portion * additional_rate)
|
|
||||||
).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
||||||
|
|
||||||
breakdown = []
|
|
||||||
if basic_portion > 0:
|
|
||||||
breakdown.append({"rate": float(basic_rate), "taxable": float(basic_portion),
|
|
||||||
"tax": float((basic_portion * basic_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))})
|
|
||||||
if higher_portion > 0:
|
|
||||||
breakdown.append({"rate": float(higher_rate), "taxable": float(higher_portion),
|
|
||||||
"tax": float((higher_portion * higher_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))})
|
|
||||||
if additional_portion > 0:
|
|
||||||
breakdown.append({"rate": float(additional_rate), "taxable": float(additional_portion),
|
|
||||||
"tax": float((additional_portion * additional_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"gross_dividends": total_dividends,
|
|
||||||
"allowance": min(total_dividends, allowance),
|
|
||||||
"taxable_dividends": taxable_dividends,
|
|
||||||
"liability": liability,
|
|
||||||
"band_breakdown": breakdown,
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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__), ".."))
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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})
|
|
||||||
|
|
@ -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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,18 @@ 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: "/predictions", icon: Sparkles, label: "Predict" },
|
||||||
{ href: "/tax", icon: Receipt, label: "Tax" },
|
{ href: "/settings", icon: Settings, label: "Settings" },
|
||||||
{ href: "/predictions", icon: Sparkles, label: "Predict" },
|
|
||||||
{ href: "/settings", icon: Settings, label: "Settings" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MobileNav() {
|
export default function MobileNav() {
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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></>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 > 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue