Salary and other income marked is_recurring=True were being returned alongside expense subscriptions. Added type='expense' filter so only outgoing recurring transactions appear. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
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,
|
|
}
|