- 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>
215 lines
5.6 KiB
Python
215 lines
5.6 KiB
Python
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
|