Add recurring transaction detection, subscriptions page, and UK tax reporting

- Recurring service: auto-detects direct debits/subscriptions from CSV imports
  using frequency analysis; manual toggle in transaction detail drawer
- Subscriptions page (/subscriptions): groups recurring payments with monthly
  cost equivalents, next-payment badges, and re-scan trigger
- UK Tax page (/tax): payslips/P60 entry, income tax + NI + CGT + dividend tax
  calculations, configurable rate tables per tax year (pre-seeded 2024/25 and
  2025/26), editable in-app so Budget changes need no rebuild
- Migration 0006: tax_rate_configs, tax_profiles, payslips, manual_cgt_disposals
  with RLS; seeds 2025/2026 rate configs for existing users
- Chart tooltip fix: all Recharts tooltips now use TOOLTIP_STYLE constant so
  they render correctly across all dark/light themes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-23 21:40:02 +00:00
parent 0b326cbd87
commit afb5e99bb2
48 changed files with 6238 additions and 39 deletions

215
backend/app/schemas/tax.py Normal file
View file

@ -0,0 +1,215 @@
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