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

@ -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,
}