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:
parent
0b326cbd87
commit
afb5e99bb2
48 changed files with 6238 additions and 39 deletions
215
backend/app/schemas/tax.py
Normal file
215
backend/app/schemas/tax.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue