Adds a full pensions feature: SIPP/workplace DC/LISA account metadata, contribution recording with relief-at-source/net-pay/salary-sacrifice gross calculations, state pension tracker, annual allowance monitor, and LISA summary. Pension contributions feed into the tax report (RAS gross totals, allowance used). Includes two Alembic migrations, backend service/schema/API, and full frontend pensions page with cards for allowance, state pension, LISA, and retirement projection. Also fixes CSRF cookie secure flag (must be false for HTTP deployments) and extends tax schemas/service to expose pension data in the report. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
227 lines
5.9 KiB
Python
227 lines
5.9 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 PensionTaxSummary(BaseModel):
|
|
net_pay_total: str
|
|
salary_sacrifice_total: str
|
|
ras_gross_total: str
|
|
higher_rate_claimable: str
|
|
additional_rate_claimable: str
|
|
annual_allowance_used: str
|
|
annual_allowance_remaining: str
|
|
standard_allowance: str
|
|
|
|
|
|
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
|
|
pensions: PensionTaxSummary | None
|
|
summary: TaxReportSummary
|