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
|
|
@ -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)
|
||||
|
|
|
|||
123
backend/app/api/v1/subscriptions.py
Normal file
123
backend/app/api/v1/subscriptions.py
Normal 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
293
backend/app/api/v1/tax.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue