Update app/services.py
Browse files- app/services.py +184 -28
app/services.py
CHANGED
|
@@ -22,11 +22,12 @@ CURRENCY_SYMBOLS: Dict[str, str] = {
|
|
| 22 |
|
| 23 |
@dataclass(frozen=True)
|
| 24 |
class MonthlySummary:
|
| 25 |
-
"""Aggregate spending for a single month and
|
| 26 |
|
| 27 |
year: int
|
| 28 |
month: int
|
| 29 |
category: str
|
|
|
|
| 30 |
total: float
|
| 31 |
|
| 32 |
@property
|
|
@@ -35,15 +36,15 @@ class MonthlySummary:
|
|
| 35 |
|
| 36 |
|
| 37 |
def _bucket_transactions(transactions: Iterable[Transaction]) -> List[MonthlySummary]:
|
| 38 |
-
"""Aggregate transactions per month and
|
| 39 |
-
buckets: Dict[Tuple[int, int, str], float] = defaultdict(float)
|
| 40 |
for txn in transactions:
|
| 41 |
-
key = (txn.timestamp.year, txn.timestamp.month, txn.category)
|
| 42 |
buckets[key] += txn.amount
|
| 43 |
|
| 44 |
summaries = [
|
| 45 |
-
MonthlySummary(year=year, month=month, category=category, total=round(total, 2))
|
| 46 |
-
for (year, month, category), total in buckets.items()
|
| 47 |
]
|
| 48 |
logger.debug("Created %d monthly summaries", len(summaries))
|
| 49 |
return summaries
|
|
@@ -73,9 +74,10 @@ def generate_insights(transactions: Iterable[Transaction]) -> Tuple[List[Insight
|
|
| 73 |
summaries.sort(key=_month_key, reverse=True)
|
| 74 |
recent_months = summaries[:12] # guardrail, though we only surface up to 3 months
|
| 75 |
|
| 76 |
-
|
|
|
|
| 77 |
for summary in recent_months:
|
| 78 |
-
grouped[summary.category].append(summary)
|
| 79 |
|
| 80 |
latest_month = max(recent_months, key=_month_key)
|
| 81 |
latest_month_dt = _month_key(latest_month)
|
|
@@ -86,9 +88,7 @@ def generate_insights(transactions: Iterable[Transaction]) -> Tuple[List[Insight
|
|
| 86 |
evaluated_months.append((latest_month_dt - relativedelta(months=offset)).strftime("%Y-%m"))
|
| 87 |
evaluated_months = sorted(set(evaluated_months))
|
| 88 |
|
| 89 |
-
currency
|
| 90 |
-
|
| 91 |
-
for category, entries in grouped.items():
|
| 92 |
entries.sort(key=_month_key, reverse=True)
|
| 93 |
current = entries[0]
|
| 94 |
history = entries[1:3]
|
|
@@ -116,26 +116,30 @@ def generate_insights(transactions: Iterable[Transaction]) -> Tuple[List[Insight
|
|
| 116 |
)
|
| 117 |
)
|
| 118 |
|
| 119 |
-
# Additional highlight: biggest month-over-month change
|
| 120 |
-
|
|
|
|
| 121 |
for summary in summaries:
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
)
|
| 137 |
)
|
| 138 |
-
)
|
| 139 |
|
| 140 |
if not insights:
|
| 141 |
insights.append(
|
|
@@ -145,3 +149,155 @@ def generate_insights(transactions: Iterable[Transaction]) -> Tuple[List[Insight
|
|
| 145 |
logger.info("Generated %d insights", len(insights))
|
| 146 |
return insights, evaluated_months
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
@dataclass(frozen=True)
|
| 24 |
class MonthlySummary:
|
| 25 |
+
"""Aggregate spending for a single month, category, and currency."""
|
| 26 |
|
| 27 |
year: int
|
| 28 |
month: int
|
| 29 |
category: str
|
| 30 |
+
currency: str
|
| 31 |
total: float
|
| 32 |
|
| 33 |
@property
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
def _bucket_transactions(transactions: Iterable[Transaction]) -> List[MonthlySummary]:
|
| 39 |
+
"""Aggregate transactions per month, category, and currency."""
|
| 40 |
+
buckets: Dict[Tuple[int, int, str, str], float] = defaultdict(float)
|
| 41 |
for txn in transactions:
|
| 42 |
+
key = (txn.timestamp.year, txn.timestamp.month, txn.category, txn.currency)
|
| 43 |
buckets[key] += txn.amount
|
| 44 |
|
| 45 |
summaries = [
|
| 46 |
+
MonthlySummary(year=year, month=month, category=category, currency=currency, total=round(total, 2))
|
| 47 |
+
for (year, month, category, currency), total in buckets.items()
|
| 48 |
]
|
| 49 |
logger.debug("Created %d monthly summaries", len(summaries))
|
| 50 |
return summaries
|
|
|
|
| 74 |
summaries.sort(key=_month_key, reverse=True)
|
| 75 |
recent_months = summaries[:12] # guardrail, though we only surface up to 3 months
|
| 76 |
|
| 77 |
+
# Group by category and currency to handle multi-currency scenarios
|
| 78 |
+
grouped: Dict[Tuple[str, str], List[MonthlySummary]] = defaultdict(list)
|
| 79 |
for summary in recent_months:
|
| 80 |
+
grouped[(summary.category, summary.currency)].append(summary)
|
| 81 |
|
| 82 |
latest_month = max(recent_months, key=_month_key)
|
| 83 |
latest_month_dt = _month_key(latest_month)
|
|
|
|
| 88 |
evaluated_months.append((latest_month_dt - relativedelta(months=offset)).strftime("%Y-%m"))
|
| 89 |
evaluated_months = sorted(set(evaluated_months))
|
| 90 |
|
| 91 |
+
for (category, currency), entries in grouped.items():
|
|
|
|
|
|
|
| 92 |
entries.sort(key=_month_key, reverse=True)
|
| 93 |
current = entries[0]
|
| 94 |
history = entries[1:3]
|
|
|
|
| 116 |
)
|
| 117 |
)
|
| 118 |
|
| 119 |
+
# Additional highlight: biggest month-over-month change per currency
|
| 120 |
+
# Group monthly totals by currency to avoid mixing currencies
|
| 121 |
+
monthly_totals_by_currency: Dict[str, Dict[str, float]] = defaultdict(lambda: defaultdict(float))
|
| 122 |
for summary in summaries:
|
| 123 |
+
monthly_totals_by_currency[summary.currency][summary.iso_month] += summary.total
|
| 124 |
+
|
| 125 |
+
# Generate insights per currency
|
| 126 |
+
for currency, monthly_totals in monthly_totals_by_currency.items():
|
| 127 |
+
sorted_months = sorted(monthly_totals.items(), reverse=True)
|
| 128 |
+
if len(sorted_months) >= 2:
|
| 129 |
+
latest_label, latest_total = sorted_months[0]
|
| 130 |
+
prev_label, prev_total = sorted_months[1]
|
| 131 |
+
delta = latest_total - prev_total
|
| 132 |
+
if abs(delta) >= 1:
|
| 133 |
+
descriptor = "more" if delta > 0 else "less"
|
| 134 |
+
currency_note = f" (in {currency})" if len(monthly_totals_by_currency) > 1 else ""
|
| 135 |
+
insights.append(
|
| 136 |
+
Insight(
|
| 137 |
+
message=(
|
| 138 |
+
f"You spent {_format_currency(abs(delta), currency)} {descriptor} in {latest_label} compared "
|
| 139 |
+
f"to {prev_label}{currency_note}. Consider reviewing large outliers."
|
| 140 |
+
)
|
| 141 |
)
|
| 142 |
)
|
|
|
|
| 143 |
|
| 144 |
if not insights:
|
| 145 |
insights.append(
|
|
|
|
| 149 |
logger.info("Generated %d insights", len(insights))
|
| 150 |
return insights, evaluated_months
|
| 151 |
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# from __future__ import annotations
|
| 158 |
+
|
| 159 |
+
# import logging
|
| 160 |
+
# from collections import defaultdict
|
| 161 |
+
# from dataclasses import dataclass
|
| 162 |
+
# from datetime import datetime
|
| 163 |
+
# from typing import Dict, Iterable, List, Tuple
|
| 164 |
+
|
| 165 |
+
# from dateutil.relativedelta import relativedelta
|
| 166 |
+
|
| 167 |
+
# from .schemas import Insight, Transaction
|
| 168 |
+
|
| 169 |
+
# logger = logging.getLogger(__name__)
|
| 170 |
+
|
| 171 |
+
# CURRENCY_SYMBOLS: Dict[str, str] = {
|
| 172 |
+
# "INR": "₹",
|
| 173 |
+
# "USD": "$",
|
| 174 |
+
# "EUR": "€",
|
| 175 |
+
# "GBP": "£",
|
| 176 |
+
# }
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# @dataclass(frozen=True)
|
| 180 |
+
# class MonthlySummary:
|
| 181 |
+
# """Aggregate spending for a single month and category."""
|
| 182 |
+
|
| 183 |
+
# year: int
|
| 184 |
+
# month: int
|
| 185 |
+
# category: str
|
| 186 |
+
# total: float
|
| 187 |
+
|
| 188 |
+
# @property
|
| 189 |
+
# def iso_month(self) -> str:
|
| 190 |
+
# return f"{self.year:04d}-{self.month:02d}"
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# def _bucket_transactions(transactions: Iterable[Transaction]) -> List[MonthlySummary]:
|
| 194 |
+
# """Aggregate transactions per month and category."""
|
| 195 |
+
# buckets: Dict[Tuple[int, int, str], float] = defaultdict(float)
|
| 196 |
+
# for txn in transactions:
|
| 197 |
+
# key = (txn.timestamp.year, txn.timestamp.month, txn.category)
|
| 198 |
+
# buckets[key] += txn.amount
|
| 199 |
+
|
| 200 |
+
# summaries = [
|
| 201 |
+
# MonthlySummary(year=year, month=month, category=category, total=round(total, 2))
|
| 202 |
+
# for (year, month, category), total in buckets.items()
|
| 203 |
+
# ]
|
| 204 |
+
# logger.debug("Created %d monthly summaries", len(summaries))
|
| 205 |
+
# return summaries
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# def _format_currency(amount: float, currency: str) -> str:
|
| 209 |
+
# symbol = CURRENCY_SYMBOLS.get(currency.upper(), "")
|
| 210 |
+
# formatted_amount = f"{amount:,.2f}"
|
| 211 |
+
# return f"{symbol}{formatted_amount}" if symbol else f"{formatted_amount} {currency.upper()}"
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# def _month_key(summary: MonthlySummary) -> datetime:
|
| 215 |
+
# return datetime(summary.year, summary.month, 1)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# def generate_insights(transactions: Iterable[Transaction]) -> Tuple[List[Insight], List[str]]:
|
| 219 |
+
# """Create AI-inspired insights from historical spending."""
|
| 220 |
+
# txns = list(transactions)
|
| 221 |
+
# if not txns:
|
| 222 |
+
# logger.info("No transactions provided, returning onboarding insight")
|
| 223 |
+
# return [Insight(message="Add a few expenses to start seeing personalized insights.")], []
|
| 224 |
+
|
| 225 |
+
# summaries = _bucket_transactions(txns)
|
| 226 |
+
# if not summaries:
|
| 227 |
+
# return [Insight(message="No spending data yet. Track expenses to unlock insights.")], []
|
| 228 |
+
|
| 229 |
+
# summaries.sort(key=_month_key, reverse=True)
|
| 230 |
+
# recent_months = summaries[:12] # guardrail, though we only surface up to 3 months
|
| 231 |
+
|
| 232 |
+
# grouped: Dict[str, List[MonthlySummary]] = defaultdict(list)
|
| 233 |
+
# for summary in recent_months:
|
| 234 |
+
# grouped[summary.category].append(summary)
|
| 235 |
+
|
| 236 |
+
# latest_month = max(recent_months, key=_month_key)
|
| 237 |
+
# latest_month_dt = _month_key(latest_month)
|
| 238 |
+
# evaluated_months = []
|
| 239 |
+
|
| 240 |
+
# insights: List[Insight] = []
|
| 241 |
+
# for offset in range(3):
|
| 242 |
+
# evaluated_months.append((latest_month_dt - relativedelta(months=offset)).strftime("%Y-%m"))
|
| 243 |
+
# evaluated_months = sorted(set(evaluated_months))
|
| 244 |
+
|
| 245 |
+
# currency = txns[0].currency
|
| 246 |
+
|
| 247 |
+
# for category, entries in grouped.items():
|
| 248 |
+
# entries.sort(key=_month_key, reverse=True)
|
| 249 |
+
# current = entries[0]
|
| 250 |
+
# history = entries[1:3]
|
| 251 |
+
# if not history:
|
| 252 |
+
# continue
|
| 253 |
+
|
| 254 |
+
# history_avg = sum(e.total for e in history) / len(history)
|
| 255 |
+
# if history_avg == 0:
|
| 256 |
+
# continue
|
| 257 |
+
|
| 258 |
+
# change_pct = ((current.total - history_avg) / history_avg) * 100
|
| 259 |
+
# diff_amount = current.total - history_avg
|
| 260 |
+
|
| 261 |
+
# if abs(change_pct) < 10:
|
| 262 |
+
# continue
|
| 263 |
+
|
| 264 |
+
# trend = "increased" if change_pct > 0 else "decreased"
|
| 265 |
+
# insights.append(
|
| 266 |
+
# Insight(
|
| 267 |
+
# category=category,
|
| 268 |
+
# message=(
|
| 269 |
+
# f"Your {category.lower()} spending {trend} by {abs(change_pct):.0f}% this month "
|
| 270 |
+
# f"versus your prior average, about {_format_currency(abs(diff_amount), currency)} difference."
|
| 271 |
+
# ),
|
| 272 |
+
# )
|
| 273 |
+
# )
|
| 274 |
+
|
| 275 |
+
# # Additional highlight: biggest month-over-month change regardless of category
|
| 276 |
+
# monthly_totals: Dict[str, float] = defaultdict(float)
|
| 277 |
+
# for summary in summaries:
|
| 278 |
+
# monthly_totals[summary.iso_month] += summary.total
|
| 279 |
+
|
| 280 |
+
# sorted_months = sorted(monthly_totals.items(), reverse=True)
|
| 281 |
+
# if len(sorted_months) >= 2:
|
| 282 |
+
# latest_label, latest_total = sorted_months[0]
|
| 283 |
+
# prev_label, prev_total = sorted_months[1]
|
| 284 |
+
# delta = latest_total - prev_total
|
| 285 |
+
# if abs(delta) >= 1:
|
| 286 |
+
# descriptor = "more" if delta > 0 else "less"
|
| 287 |
+
# insights.append(
|
| 288 |
+
# Insight(
|
| 289 |
+
# message=(
|
| 290 |
+
# f"You spent {_format_currency(abs(delta), currency)} {descriptor} in {latest_label} compared "
|
| 291 |
+
# f"to {prev_label}. Consider reviewing large outliers."
|
| 292 |
+
# )
|
| 293 |
+
# )
|
| 294 |
+
# )
|
| 295 |
+
|
| 296 |
+
# if not insights:
|
| 297 |
+
# insights.append(
|
| 298 |
+
# Insight(message="Spending is stable across tracked months. Keep up the steady habits!")
|
| 299 |
+
# )
|
| 300 |
+
|
| 301 |
+
# logger.info("Generated %d insights", len(insights))
|
| 302 |
+
# return insights, evaluated_months
|
| 303 |
+
|