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

View file

@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin, settings
from app.api.v1 import auth, users, accounts, categories, transactions, budgets, reports, investments, predictions, admin, settings, subscriptions, tax
router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["auth"])
@ -14,3 +14,5 @@ router.include_router(investments.router)
router.include_router(predictions.router)
router.include_router(admin.router)
router.include_router(settings.router)
router.include_router(subscriptions.router)
router.include_router(tax.router)

View file

@ -0,0 +1,123 @@
from __future__ import annotations
from collections import defaultdict
from decimal import Decimal
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decrypt_field
from app.db.models.account import Account
from app.db.models.transaction import Transaction
from app.dependencies import get_current_user, get_db
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
_MONTHLY_FACTORS = {
"weekly": Decimal("52") / Decimal("12"),
"fortnightly": Decimal("26") / Decimal("12"),
"monthly": Decimal("1"),
"quarterly": Decimal("1") / Decimal("3"),
"yearly": Decimal("1") / Decimal("12"),
}
@router.get("")
async def get_subscriptions(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
"""Return all detected recurring transactions grouped as subscriptions."""
txn_result = await db.execute(
select(Transaction).where(
Transaction.user_id == user.id,
Transaction.is_recurring == True,
Transaction.deleted_at.is_(None),
)
)
transactions = txn_result.scalars().all()
# Load accounts for name lookup
acc_result = await db.execute(
select(Account).where(
Account.user_id == user.id,
Account.deleted_at.is_(None),
)
)
account_map = {a.id: a for a in acc_result.scalars().all()}
# Group by (normalised frequency+amount key from recurring_rule)
# Use (frequency, typical_amount, normalised_name) as the grouping key
# so manually-set entries with no rule still appear individually
from app.services.recurring_service import normalise_description
# Group: key → list of transactions
groups: dict[str, list[Transaction]] = defaultdict(list)
for txn in transactions:
rule = txn.recurring_rule or {}
freq = rule.get("frequency", "unknown")
amt = rule.get("typical_amount", float(txn.amount))
try:
desc = decrypt_field(txn.description_enc) or ""
except Exception:
desc = ""
norm = normalise_description(desc)
key = f"{norm}|{amt}|{freq}"
groups[key].append(txn)
subscriptions = []
total_monthly = Decimal("0")
for key, txns in groups.items():
# Use the transaction with the most recent date as the representative
txns_sorted = sorted(txns, key=lambda t: t.date, reverse=True)
latest = txns_sorted[0]
rule = latest.recurring_rule or {}
freq = rule.get("frequency", "unknown")
amount = Decimal(str(rule.get("typical_amount", float(latest.amount))))
next_expected = rule.get("next_expected")
last_paid = rule.get("last_paid") or str(latest.date)
confidence = rule.get("confidence", 1.0)
manually_set = rule.get("manually_set", False)
try:
desc = decrypt_field(latest.description_enc) or ""
except Exception:
desc = ""
account = account_map.get(latest.account_id)
try:
account_name = decrypt_field(account.name_enc) if account else None
except Exception:
account_name = None
factor = _MONTHLY_FACTORS.get(freq, Decimal("1"))
monthly_equiv = abs(amount) * factor
total_monthly += monthly_equiv
subscriptions.append({
"name": desc,
"amount": float(amount),
"frequency": freq,
"next_expected": next_expected,
"last_paid": last_paid,
"account_id": str(latest.account_id),
"account_name": account_name,
"transaction_ids": [str(t.id) for t in txns],
"latest_transaction_id": str(latest.id),
"monthly_equivalent": float(monthly_equiv.quantize(Decimal("0.01"))),
"confidence": confidence,
"manually_set": manually_set,
})
# Sort by next_expected ascending (soonest first), nulls last
subscriptions.sort(key=lambda s: s["next_expected"] or "9999-99-99")
return {
"total_monthly_equivalent": float(total_monthly.quantize(Decimal("0.01"))),
"currency": user.base_currency,
"subscriptions": subscriptions,
}

293
backend/app/api/v1/tax.py Normal file
View file

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

View file

@ -611,6 +611,18 @@ async def import_transactions(
return result
@router.post("/detect-recurring")
async def detect_recurring_endpoint(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
"""Manually trigger recurring transaction detection for the current user."""
from app.services.recurring_service import detect_recurring
result = await detect_recurring(db, user.id)
await db.commit()
return result
@router.get("/import/template")
async def import_template():
from fastapi.responses import Response