- 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>
293 lines
9.8 KiB
Python
293 lines
9.8 KiB
Python
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))
|