Files
SpendingAnalysis/src/services/recurring.py
T
andy db06108d2b feat: complete v1 implementation - all services, UI views, and tests
Adds remaining files from parallel development:
- Services: analysis, csv_reader, forecasting, normalizer, recurring
- UI: recurring_view, settings_view, sidebar, themes (dark/light)
- Tests: analysis, csv_reader, forecasting, import_categorize,
  normalizer, recurring, integration
- App entry point (main.py) and CLAUDE.md

52 tests passing across all modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:57:46 -05:00

70 lines
2.3 KiB
Python

# src/services/recurring.py
from collections import defaultdict
from sqlalchemy.orm import Session
from src.models.transaction import Transaction
class RecurringDetector:
def __init__(self, session: Session):
self.session = session
def detect(self, amount_tolerance: float = 0.10) -> list[dict]:
txns = (
self.session.query(Transaction)
.filter(Transaction.amount < 0)
.filter(Transaction.is_transfer == False)
.order_by(Transaction.date)
.all()
)
groups: dict[str, list[Transaction]] = defaultdict(list)
for txn in txns:
groups[txn.description].append(txn)
results = []
for desc, group in groups.items():
if len(group) < 2:
continue
amounts = [abs(float(t.amount)) for t in group]
avg_amount = sum(amounts) / len(amounts)
if any(abs(a - avg_amount) / avg_amount > amount_tolerance for a in amounts):
continue
dates = sorted(t.date for t in group)
intervals = [(dates[i + 1] - dates[i]).days for i in range(len(dates) - 1)]
avg_interval = sum(intervals) / len(intervals)
frequency = self._classify_frequency(avg_interval)
if frequency is None:
continue
annual_multiplier = {"weekly": 52, "biweekly": 26, "monthly": 12, "quarterly": 4, "annual": 1}
results.append({
"description": desc,
"typical_amount": round(avg_amount, 2),
"frequency": frequency,
"annual_cost": round(avg_amount * annual_multiplier[frequency], 2),
"occurrences": len(group),
"last_date": max(dates),
})
results.sort(key=lambda r: r["annual_cost"], reverse=True)
return results
def _classify_frequency(self, avg_days: float) -> str | None:
if 5 <= avg_days <= 9:
return "weekly"
elif 12 <= avg_days <= 16:
return "biweekly"
elif 25 <= avg_days <= 35:
return "monthly"
elif 80 <= avg_days <= 100:
return "quarterly"
elif 350 <= avg_days <= 380:
return "annual"
return None