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.type == "expense", 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, }