finance-intelligence-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
__init__.py ADDED
File without changes
analytics.py ADDED
@@ -0,0 +1,184 @@
1
+ import os
2
+ from datetime import date as pydate
3
+ import matplotlib
4
+ matplotlib.use('Agg') # Configure headless non-interactive backend
5
+ import matplotlib.pyplot as plt
6
+ from budget import validate_category_and_subcategory
7
+
8
+ async def expense_summary_impl(
9
+ conn,
10
+ user_id: int,
11
+ period: str = None,
12
+ group_by: str = None,
13
+ category: str = None,
14
+ subcategory: str = None,
15
+ start_date: str = None,
16
+ end_date: str = None
17
+ ) -> dict:
18
+ # 1. Parameter Validations
19
+ if not period and not group_by:
20
+ return {
21
+ "status": "error",
22
+ "message": "At least one grouping dimension ('period' or 'group_by') must be specified."
23
+ }
24
+
25
+ if period and period not in ["weekly", "monthly", "quarterly", "yearly"]:
26
+ return {
27
+ "status": "error",
28
+ "message": "Invalid period. Allowed values: 'weekly', 'monthly', 'quarterly', 'yearly'."
29
+ }
30
+
31
+ if group_by and group_by not in ["category", "subcategory"]:
32
+ return {
33
+ "status": "error",
34
+ "message": "Invalid group_by. Allowed values: 'category', 'subcategory'."
35
+ }
36
+
37
+ # 2. Date validations
38
+ try:
39
+ parsed_start = pydate.fromisoformat(start_date) if start_date else None
40
+ parsed_end = pydate.fromisoformat(end_date) if end_date else None
41
+ if parsed_start and parsed_end and parsed_start > parsed_end:
42
+ raise ValueError(f"start_date '{start_date}' cannot be after end_date '{end_date}'.")
43
+ except Exception as e:
44
+ return {
45
+ "status": "error",
46
+ "message": f"Date validation failed: {str(e)}"
47
+ }
48
+
49
+ # 3. Category / Subcategory normalization
50
+ matched_cat = None
51
+ matched_sub = None
52
+ if category:
53
+ try:
54
+ matched_cat, matched_sub = validate_category_and_subcategory(category, subcategory)
55
+ except ValueError as e:
56
+ return {
57
+ "status": "error",
58
+ "message": f"Validation failed: {str(e)}"
59
+ }
60
+
61
+ # 4. Build dynamic SQL
62
+ select_fields = []
63
+ group_fields = []
64
+
65
+ if period:
66
+ trunc_map = {
67
+ "weekly": "week",
68
+ "monthly": "month",
69
+ "quarterly": "quarter",
70
+ "yearly": "year"
71
+ }
72
+ select_fields.append(f"DATE_TRUNC('{trunc_map[period]}', date)::date AS period_bucket")
73
+ group_fields.append("period_bucket")
74
+
75
+ if group_by:
76
+ select_fields.append(f"{group_by} AS group_bucket")
77
+ group_fields.append("group_bucket")
78
+
79
+ query = (
80
+ f"SELECT {', '.join(select_fields)}, SUM(amount) as total_amount, COUNT(id) as transaction_count "
81
+ f"FROM expenses WHERE user_id = $1"
82
+ )
83
+ params = [user_id]
84
+ param_idx = 2
85
+
86
+ if parsed_start:
87
+ query += f" AND date >= ${param_idx}"
88
+ params.append(parsed_start)
89
+ param_idx += 1
90
+ if parsed_end:
91
+ query += f" AND date <= ${param_idx}"
92
+ params.append(parsed_end)
93
+ param_idx += 1
94
+ if matched_cat:
95
+ query += f" AND category = ${param_idx}"
96
+ params.append(matched_cat)
97
+ param_idx += 1
98
+ if matched_sub:
99
+ query += f" AND subcategory = ${param_idx}"
100
+ params.append(matched_sub)
101
+ param_idx += 1
102
+
103
+ query += f" GROUP BY {', '.join(group_fields)} ORDER BY {group_fields[0]} ASC"
104
+ if len(group_fields) > 1:
105
+ query += f", {group_fields[1]} ASC"
106
+
107
+ rows = await conn.fetch(query, *params)
108
+ if not rows:
109
+ return {
110
+ "status": "ok",
111
+ "message": "No expenses found matching the criteria.",
112
+ "data": []
113
+ }
114
+
115
+ # 5. Generate Matplotlib Chart
116
+ charts_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "charts")
117
+ os.makedirs(charts_dir, exist_ok=True)
118
+ chart_filename = "expense_analysis.png"
119
+ chart_path = os.path.join(charts_dir, chart_filename)
120
+
121
+ fig, ax = plt.subplots(figsize=(10, 6))
122
+
123
+ # Render different chart configurations
124
+ if period and not group_by:
125
+ # Time-series trend (Bar chart)
126
+ x_vals = [str(r["period_bucket"]) for r in rows]
127
+ y_vals = [float(r["total_amount"]) for r in rows]
128
+ ax.bar(x_vals, y_vals, color="#3f51b5", width=0.6)
129
+ ax.set_ylabel("Spent Amount ($)")
130
+ ax.set_xlabel("Period")
131
+ ax.set_title(f"Expense Trend over Time ({period.capitalize()})")
132
+ plt.xticks(rotation=45)
133
+
134
+ elif group_by and not period:
135
+ # Category breakdown (Bar chart)
136
+ x_vals = [str(r["group_bucket"]) for r in rows]
137
+ y_vals = [float(r["total_amount"]) for r in rows]
138
+ ax.bar(x_vals, y_vals, color="#e91e63", width=0.6)
139
+ ax.set_ylabel("Spent Amount ($)")
140
+ ax.set_xlabel(group_by.capitalize())
141
+ ax.set_title(f"Expenses Grouped by {group_by.capitalize()}")
142
+ plt.xticks(rotation=45)
143
+
144
+ elif period and group_by:
145
+ # Both (Stacked bar chart)
146
+ periods = sorted(list(set(str(r["period_bucket"]) for r in rows)))
147
+ groups = sorted(list(set(str(r["group_bucket"]) for r in rows)))
148
+
149
+ # Build mapping matrices
150
+ data_matrix = {g: [0.0] * len(periods) for g in groups}
151
+ period_idx_map = {p: i for i, p in enumerate(periods)}
152
+
153
+ for r in rows:
154
+ p_val = str(r["period_bucket"])
155
+ g_val = str(r["group_bucket"])
156
+ amt = float(r["total_amount"])
157
+ data_matrix[g_val][period_idx_map[p_val]] = amt
158
+
159
+ bottoms = [0.0] * len(periods)
160
+ for g_val in groups:
161
+ y_vals = data_matrix[g_val]
162
+ ax.bar(periods, y_vals, bottom=bottoms, label=g_val)
163
+ bottoms = [b + y for b, y in zip(bottoms, y_vals)]
164
+
165
+ ax.set_ylabel("Spent Amount ($)")
166
+ ax.set_xlabel("Period")
167
+ ax.set_title(f"Expenses Trend Grouped by {group_by.capitalize()}")
168
+ ax.legend(title=group_by.capitalize())
169
+ plt.xticks(rotation=45)
170
+
171
+ ax.grid(axis='y', linestyle='--', alpha=0.7)
172
+ plt.tight_layout()
173
+ plt.savefig(chart_path, dpi=150)
174
+ plt.close(fig)
175
+
176
+ chart_url = f"file:///{chart_path.replace(os.sep, '/')}"
177
+
178
+ return {
179
+ "status": "ok",
180
+ "chart_path": chart_path,
181
+ "chart_url": chart_url,
182
+ "message": f"Expense analytics trend chart generated successfully:\n\n![Expense Analytics Chart]({chart_url})",
183
+ "data": [dict(r) for r in rows]
184
+ }
budget.py ADDED
@@ -0,0 +1,558 @@
1
+ import os
2
+ import json
3
+ from datetime import date as pydate
4
+
5
+ CATEGORIES_PATH = os.path.join(os.path.dirname(__file__), "categories.json")
6
+
7
+ def validate_category_and_subcategory(category: str = None, subcategory: str = None):
8
+ if category is None:
9
+ return None, None
10
+
11
+ if not os.path.exists(CATEGORIES_PATH):
12
+ valid_categories = ["Food", "Travel", "Utilities", "Entertainment", "Health", "Other"]
13
+ matched_cat = next((c for c in valid_categories if c.lower() == category.lower()), None)
14
+ if matched_cat is None:
15
+ raise ValueError(f"Category '{category}' is invalid. Allowed: {valid_categories}")
16
+ return matched_cat, None
17
+
18
+ with open(CATEGORIES_PATH, 'r', encoding="utf-8") as f:
19
+ data = json.load(f)
20
+
21
+ categories_lower = {k.lower(): k for k in data.keys()}
22
+ category_lower = category.lower()
23
+
24
+ if category_lower not in categories_lower:
25
+ raise ValueError(f"Category '{category}' is invalid.")
26
+
27
+ matched_category_key = categories_lower[category_lower]
28
+
29
+ matched_subcategory = None
30
+ if subcategory:
31
+ subcats_lower = {s.lower(): s for s in data[matched_category_key]}
32
+ subcat_lower = subcategory.lower()
33
+ if subcat_lower not in subcats_lower:
34
+ raise ValueError(f"Subcategory '{subcategory}' is invalid for category '{matched_category_key}'.")
35
+ matched_subcategory = subcats_lower[subcat_lower]
36
+
37
+ return matched_category_key, matched_subcategory
38
+
39
+ async def create_budget_impl(
40
+ conn,
41
+ user_id: int,
42
+ budget_type: str = None,
43
+ amount: float = None,
44
+ period: str = None,
45
+ start_date: str = None,
46
+ end_date: str = None,
47
+ category: str = None,
48
+ subcategory: str = None,
49
+ budgets: list[dict] = None
50
+ ) -> dict:
51
+ budget_list = []
52
+ if budgets is not None:
53
+ budget_list = budgets
54
+ else:
55
+ if budget_type is None or amount is None or period is None or start_date is None or end_date is None:
56
+ return {
57
+ "status": "error",
58
+ "message": (
59
+ "Either 'budgets' list must be provided, or all single budget parameters "
60
+ "(budget_type, amount, period, start_date, end_date) must be provided."
61
+ )
62
+ }
63
+ budget_list = [{
64
+ "budget_type": budget_type,
65
+ "amount": amount,
66
+ "period": period,
67
+ "start_date": start_date,
68
+ "end_date": end_date,
69
+ "category": category,
70
+ "subcategory": subcategory
71
+ }]
72
+
73
+ if not budget_list:
74
+ return {
75
+ "status": "error",
76
+ "message": "No budgets specified to create."
77
+ }
78
+
79
+ validated_budgets = []
80
+ try:
81
+ for b in budget_list:
82
+ b_type = b.get("budget_type")
83
+ b_amount = b.get("amount")
84
+ b_period = b.get("period")
85
+ b_start = b.get("start_date")
86
+ b_end = b.get("end_date")
87
+ b_cat = b.get("category")
88
+ b_subcat = b.get("subcategory")
89
+
90
+ if not all([b_type, b_amount is not None, b_period, b_start, b_end]):
91
+ raise ValueError("Missing required fields in budget details.")
92
+
93
+ d_start = pydate.fromisoformat(b_start)
94
+ d_end = pydate.fromisoformat(b_end)
95
+ if d_start > d_end:
96
+ raise ValueError(f"start_date '{b_start}' cannot be after end_date '{b_end}'.")
97
+
98
+ matched_cat, matched_sub = validate_category_and_subcategory(b_cat, b_subcat)
99
+
100
+ if b_type == "overall":
101
+ if b_cat is not None or b_subcat is not None:
102
+ raise ValueError("Overall budget cannot have category or subcategory set.")
103
+ elif b_type == "category":
104
+ if b_cat is None:
105
+ raise ValueError("Category budget must specify a category.")
106
+ if b_subcat is not None:
107
+ raise ValueError("Category budget cannot specify a subcategory.")
108
+ elif b_type == "subcategory":
109
+ if b_cat is None or b_subcat is None:
110
+ raise ValueError("Subcategory budget must specify both category and subcategory.")
111
+
112
+ validated_budgets.append({
113
+ "budget_type": b_type,
114
+ "amount": float(b_amount),
115
+ "period": b_period,
116
+ "start_date": d_start,
117
+ "end_date": d_end,
118
+ "category": matched_cat,
119
+ "subcategory": matched_sub
120
+ })
121
+ except Exception as e:
122
+ return {
123
+ "status": "error",
124
+ "message": f"Validation failed: {str(e)}"
125
+ }
126
+
127
+ created_ids = []
128
+ async with conn.transaction():
129
+ for vb in validated_budgets:
130
+ bid = await conn.fetchval(
131
+ """
132
+ INSERT INTO budgets (user_id, budget_type, category, subcategory, amount, period, start_date, end_date)
133
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
134
+ RETURNING id
135
+ """,
136
+ user_id, vb["budget_type"], vb["category"], vb["subcategory"], vb["amount"], vb["period"], vb["start_date"], vb["end_date"]
137
+ )
138
+ created_ids.append(bid)
139
+
140
+ return {
141
+ "status": "ok",
142
+ "created_count": len(created_ids),
143
+ "created_ids": created_ids
144
+ }
145
+
146
+ async def list_budgets_impl(
147
+ conn,
148
+ user_id: int,
149
+ budget_type: str = None,
150
+ category: str = None,
151
+ subcategory: str = None,
152
+ period: str = None
153
+ ) -> list:
154
+ matched_cat = None
155
+ matched_sub = None
156
+
157
+ if category:
158
+ try:
159
+ matched_cat, matched_sub = validate_category_and_subcategory(category, subcategory)
160
+ except ValueError:
161
+ return []
162
+
163
+ query = (
164
+ "SELECT id, budget_type, category, subcategory, amount, period, "
165
+ "start_date::text, end_date::text FROM budgets WHERE user_id = $1"
166
+ )
167
+ params = [user_id]
168
+ param_idx = 2
169
+
170
+ if budget_type:
171
+ query += f" AND budget_type = ${param_idx}"
172
+ params.append(budget_type)
173
+ param_idx += 1
174
+
175
+ if matched_cat:
176
+ query += f" AND category = ${param_idx}"
177
+ params.append(matched_cat)
178
+ param_idx += 1
179
+
180
+ if matched_sub:
181
+ query += f" AND subcategory = ${param_idx}"
182
+ params.append(matched_sub)
183
+ param_idx += 1
184
+
185
+ if period:
186
+ query += f" AND period = ${param_idx}"
187
+ params.append(period)
188
+ param_idx += 1
189
+
190
+ query += " ORDER BY id ASC"
191
+
192
+ rows = await conn.fetch(query, *params)
193
+ return [dict(row) for row in rows]
194
+
195
+ async def update_budgets_impl(
196
+ conn,
197
+ user_id: int,
198
+ # Filters
199
+ budget_ids: list[int] = None,
200
+ filter_budget_type: str = None,
201
+ filter_category: str = None,
202
+ filter_subcategory: str = None,
203
+ filter_period: str = None,
204
+ # Update Fields
205
+ budget_type: str = None,
206
+ amount: float = None,
207
+ period: str = None,
208
+ start_date: str = None,
209
+ end_date: str = None,
210
+ category: str = None,
211
+ subcategory: str = None
212
+ ) -> dict:
213
+ # 1. Parameter Validation
214
+ if not any([budget_ids, filter_budget_type, filter_category, filter_subcategory, filter_period]):
215
+ return {
216
+ "status": "error",
217
+ "message": "At least one target filter must be specified."
218
+ }
219
+
220
+ if not any([budget_type is not None, amount is not None, period is not None,
221
+ start_date is not None, end_date is not None, category is not None, subcategory is not None]):
222
+ return {
223
+ "status": "error",
224
+ "message": "At least one budget field must be specified to update."
225
+ }
226
+
227
+ # 2. Category & Subcategory normalizations for updates
228
+ matched_update_cat = None
229
+ matched_update_sub = None
230
+ if category is not None or subcategory is not None:
231
+ try:
232
+ if subcategory is not None and category is None:
233
+ raise ValueError("Cannot update subcategory without specifying the category.")
234
+ matched_update_cat, matched_update_sub = validate_category_and_subcategory(category, subcategory)
235
+ except ValueError as e:
236
+ return {
237
+ "status": "error",
238
+ "message": f"Validation failed: {str(e)}"
239
+ }
240
+
241
+ # 3. Filter normalizations
242
+ matched_filter_cat = None
243
+ matched_filter_sub = None
244
+ if filter_category:
245
+ try:
246
+ matched_filter_cat, matched_filter_sub = validate_category_and_subcategory(filter_category, filter_subcategory)
247
+ except ValueError:
248
+ # If the filter category is invalid, there won't be any matching budgets in the DB
249
+ return {
250
+ "status": "ok",
251
+ "updated_count": 0,
252
+ "updated_ids": []
253
+ }
254
+
255
+ # 4. Date validation
256
+ try:
257
+ parsed_start = pydate.fromisoformat(start_date) if start_date else None
258
+ parsed_end = pydate.fromisoformat(end_date) if end_date else None
259
+ if parsed_start and parsed_end and parsed_start > parsed_end:
260
+ raise ValueError(f"start_date '{start_date}' cannot be after end_date '{end_date}'.")
261
+ except Exception as e:
262
+ return {
263
+ "status": "error",
264
+ "message": f"Date validation failed: {str(e)}"
265
+ }
266
+
267
+ # 5. Build dynamic SQL
268
+ set_clauses = []
269
+ params = []
270
+ param_idx = 1
271
+
272
+ # SET parameters
273
+ if budget_type is not None:
274
+ set_clauses.append(f"budget_type = ${param_idx}")
275
+ params.append(budget_type)
276
+ param_idx += 1
277
+ if amount is not None:
278
+ set_clauses.append(f"amount = ${param_idx}")
279
+ params.append(float(amount))
280
+ param_idx += 1
281
+ if period is not None:
282
+ set_clauses.append(f"period = ${param_idx}")
283
+ params.append(period)
284
+ param_idx += 1
285
+ if start_date is not None:
286
+ set_clauses.append(f"start_date = ${param_idx}")
287
+ params.append(parsed_start)
288
+ param_idx += 1
289
+ if end_date is not None:
290
+ set_clauses.append(f"end_date = ${param_idx}")
291
+ params.append(parsed_end)
292
+ param_idx += 1
293
+ if category is not None:
294
+ set_clauses.append(f"category = ${param_idx}")
295
+ params.append(matched_update_cat)
296
+ param_idx += 1
297
+ if subcategory is not None:
298
+ set_clauses.append(f"subcategory = ${param_idx}")
299
+ params.append(matched_update_sub)
300
+ param_idx += 1
301
+
302
+ set_clauses.append("updated_at = CURRENT_TIMESTAMP")
303
+
304
+ # WHERE parameters (user restriction is always present)
305
+ where_clauses = [f"user_id = ${param_idx}"]
306
+ params.append(user_id)
307
+ param_idx += 1
308
+
309
+ if budget_ids:
310
+ where_clauses.append(f"id = ANY(${param_idx}::integer[])")
311
+ params.append(budget_ids)
312
+ param_idx += 1
313
+ if filter_budget_type:
314
+ where_clauses.append(f"budget_type = ${param_idx}")
315
+ params.append(filter_budget_type)
316
+ param_idx += 1
317
+ if matched_filter_cat:
318
+ where_clauses.append(f"category = ${param_idx}")
319
+ params.append(matched_filter_cat)
320
+ param_idx += 1
321
+ if matched_filter_sub:
322
+ where_clauses.append(f"subcategory = ${param_idx}")
323
+ params.append(matched_filter_sub)
324
+ param_idx += 1
325
+ if filter_period:
326
+ where_clauses.append(f"period = ${param_idx}")
327
+ params.append(filter_period)
328
+ param_idx += 1
329
+
330
+ query = f"UPDATE budgets SET {', '.join(set_clauses)} WHERE {' AND '.join(where_clauses)} RETURNING id"
331
+
332
+ try:
333
+ rows = await conn.fetch(query, *params)
334
+ updated_ids = [row["id"] for row in rows]
335
+ return {
336
+ "status": "ok",
337
+ "updated_count": len(updated_ids),
338
+ "updated_ids": updated_ids
339
+ }
340
+ except Exception as e:
341
+ return {
342
+ "status": "error",
343
+ "message": f"Update failed: {str(e)}"
344
+ }
345
+
346
+ async def delete_budgets_impl(
347
+ conn,
348
+ user_id: int,
349
+ budget_ids: list[int] = None,
350
+ start_date: str = None,
351
+ end_date: str = None,
352
+ budget_type: str = None,
353
+ category: str = None,
354
+ subcategory: str = None,
355
+ period: str = None
356
+ ) -> dict:
357
+ # 1. Safety verification
358
+ if not any([budget_ids, start_date, end_date, budget_type, category, subcategory, period]):
359
+ return {
360
+ "status": "error",
361
+ "message": "At least one target filter must be specified to delete budgets."
362
+ }
363
+
364
+ # 2. Casing normalization for category/subcategory filters
365
+ matched_filter_cat = None
366
+ matched_filter_sub = None
367
+ if category:
368
+ try:
369
+ matched_filter_cat, matched_filter_sub = validate_category_and_subcategory(category, subcategory)
370
+ except ValueError:
371
+ # If the filter category is invalid, there won't be any matching budgets in the DB to delete
372
+ return {
373
+ "status": "ok",
374
+ "deleted_count": 0,
375
+ "deleted_ids": []
376
+ }
377
+
378
+ # 3. Date validation
379
+ try:
380
+ parsed_start = pydate.fromisoformat(start_date) if start_date else None
381
+ parsed_end = pydate.fromisoformat(end_date) if end_date else None
382
+ if parsed_start and parsed_end and parsed_start > parsed_end:
383
+ raise ValueError(f"start_date '{start_date}' cannot be after end_date '{end_date}'.")
384
+ except Exception as e:
385
+ return {
386
+ "status": "error",
387
+ "message": f"Date validation failed: {str(e)}"
388
+ }
389
+
390
+ # 4. Build dynamic query
391
+ where_clauses = ["user_id = $1"]
392
+ params = [user_id]
393
+ param_idx = 2
394
+
395
+ if budget_ids:
396
+ where_clauses.append(f"id = ANY(${param_idx}::integer[])")
397
+ params.append(budget_ids)
398
+ param_idx += 1
399
+ if parsed_start:
400
+ where_clauses.append(f"start_date >= ${param_idx}")
401
+ params.append(parsed_start)
402
+ param_idx += 1
403
+ if parsed_end:
404
+ where_clauses.append(f"end_date <= ${param_idx}")
405
+ params.append(parsed_end)
406
+ param_idx += 1
407
+ if budget_type:
408
+ where_clauses.append(f"budget_type = ${param_idx}")
409
+ params.append(budget_type)
410
+ param_idx += 1
411
+ if matched_filter_cat:
412
+ where_clauses.append(f"category = ${param_idx}")
413
+ params.append(matched_filter_cat)
414
+ param_idx += 1
415
+ if matched_filter_sub:
416
+ where_clauses.append(f"subcategory = ${param_idx}")
417
+ params.append(matched_filter_sub)
418
+ param_idx += 1
419
+ if period:
420
+ where_clauses.append(f"period = ${param_idx}")
421
+ params.append(period)
422
+ param_idx += 1
423
+
424
+ query = f"DELETE FROM budgets WHERE {' AND '.join(where_clauses)} RETURNING id"
425
+
426
+ try:
427
+ rows = await conn.fetch(query, *params)
428
+ deleted_ids = [row["id"] for row in rows]
429
+ return {
430
+ "status": "ok",
431
+ "deleted_count": len(deleted_ids),
432
+ "deleted_ids": deleted_ids
433
+ }
434
+ except Exception as e:
435
+ return {
436
+ "status": "error",
437
+ "message": f"Deletion failed: {str(e)}"
438
+ }
439
+
440
+ async def compare_budget_vs_expenses_impl(
441
+ conn,
442
+ user_id: int,
443
+ reference_date: str = None,
444
+ budget_type: str = None,
445
+ category: str = None,
446
+ subcategory: str = None,
447
+ period: str = None
448
+ ) -> dict:
449
+ # 1. Resolve reference date
450
+ try:
451
+ ref_date = pydate.fromisoformat(reference_date) if reference_date else pydate.today()
452
+ except Exception as e:
453
+ return {
454
+ "status": "error",
455
+ "message": f"Invalid reference_date format: {str(e)}"
456
+ }
457
+
458
+ # 2. Casing normalization for category/subcategory filters
459
+ matched_filter_cat = None
460
+ matched_filter_sub = None
461
+ if category:
462
+ try:
463
+ matched_filter_cat, matched_filter_sub = validate_category_and_subcategory(category, subcategory)
464
+ except ValueError:
465
+ return {
466
+ "status": "ok",
467
+ "reference_date": ref_date.isoformat(),
468
+ "budgets": []
469
+ }
470
+
471
+ # 3. Retrieve matching active budgets
472
+ query = (
473
+ "SELECT id, budget_type, category, subcategory, amount, period, "
474
+ "start_date::text, end_date::text FROM budgets "
475
+ "WHERE user_id = $1 AND start_date <= $2 AND end_date >= $2"
476
+ )
477
+ params = [user_id, ref_date]
478
+ param_idx = 3
479
+
480
+ if budget_type:
481
+ query += f" AND budget_type = ${param_idx}"
482
+ params.append(budget_type)
483
+ param_idx += 1
484
+ if matched_filter_cat:
485
+ query += f" AND category = ${param_idx}"
486
+ params.append(matched_filter_cat)
487
+ param_idx += 1
488
+ if matched_filter_sub:
489
+ query += f" AND subcategory = ${param_idx}"
490
+ params.append(matched_filter_sub)
491
+ param_idx += 1
492
+ if period:
493
+ query += f" AND period = ${param_idx}"
494
+ params.append(period)
495
+ param_idx += 1
496
+
497
+ query += " ORDER BY id ASC"
498
+ rows = await conn.fetch(query, *params)
499
+
500
+ # 4. Aggregating expenses for each budget
501
+ budget_status_list = []
502
+ for r in rows:
503
+ bid = r["id"]
504
+ b_type = r["budget_type"]
505
+ b_cat = r["category"]
506
+ b_sub = r["subcategory"]
507
+ b_amount = r["amount"]
508
+ b_period = r["period"]
509
+ b_start = pydate.fromisoformat(r["start_date"])
510
+ b_end = pydate.fromisoformat(r["end_date"])
511
+
512
+ # Construct expense sum query
513
+ exp_query = (
514
+ "SELECT COALESCE(SUM(amount), 0.0) FROM expenses "
515
+ "WHERE user_id = $1 AND date BETWEEN $2 AND $3"
516
+ )
517
+ exp_params = [user_id, b_start, b_end]
518
+ exp_param_idx = 4
519
+
520
+ if b_type == "category":
521
+ exp_query += f" AND category = ${exp_param_idx}"
522
+ exp_params.append(b_cat)
523
+ exp_param_idx += 1
524
+ elif b_type == "subcategory":
525
+ exp_query += f" AND category = ${exp_param_idx} AND subcategory = ${exp_param_idx+1}"
526
+ exp_params.extend([b_cat, b_sub])
527
+ exp_param_idx += 2
528
+
529
+ total_spent = await conn.fetchval(exp_query, *exp_params)
530
+
531
+ # Calculations
532
+ remaining = b_amount - total_spent
533
+ percentage = (total_spent / b_amount) * 100.0 if b_amount > 0 else (100.0 if total_spent > 0 else 0.0)
534
+ status_label = "over_budget" if total_spent > b_amount else "under_budget"
535
+
536
+ budget_status_list.append({
537
+ "budget_id": bid,
538
+ "budget_type": b_type,
539
+ "category": b_cat,
540
+ "subcategory": b_sub,
541
+ "period": b_period,
542
+ "start_date": r["start_date"],
543
+ "end_date": r["end_date"],
544
+ "limit_amount": b_amount,
545
+ "total_spent": total_spent,
546
+ "remaining": remaining,
547
+ "percentage_spent": round(percentage, 2),
548
+ "status": status_label
549
+ })
550
+
551
+ return {
552
+ "status": "ok",
553
+ "reference_date": ref_date.isoformat(),
554
+ "budgets": budget_status_list
555
+ }
556
+
557
+
558
+
@@ -0,0 +1,278 @@
1
+ Metadata-Version: 2.4
2
+ Name: finance-intelligence-mcp
3
+ Version: 0.1.0
4
+ Summary: A production-ready Model Context Protocol (MCP) server for private personal finance management.
5
+ Author-email: Ronit Rajput <ronitrajput182005@gmail.com>
6
+ License: MIT
7
+ Keywords: mcp,fastmcp,finance,postgresql,ai,llm
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: asyncpg>=0.31.0
15
+ Requires-Dist: fastmcp>=3.4.2
16
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.51
17
+ Requires-Dist: python-dotenv>=1.0.1
18
+ Requires-Dist: matplotlib>=3.8.0
19
+ Requires-Dist: openpyxl>=3.1.2
20
+ Dynamic: license-file
21
+
22
+ # Finance Intelligence MCP Server
23
+
24
+ [![Python Version](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue.svg)](https://www.python.org/)
25
+ [![FastMCP](https://img.shields.io/badge/framework-FastMCP-brightgreen.svg)](https://github.com/jlowin/fastmcp)
26
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
27
+ [![MCP Specification](https://img.shields.io/badge/MCP-Compatible-orange.svg)](https://modelcontextprotocol.io/)
28
+ [![Database](https://img.shields.io/badge/database-PostgreSQL-blue.svg)](https://www.postgresql.org/)
29
+
30
+ Finance Intelligence MCP is a production-ready **Model Context Protocol (MCP)** server that enables AI assistants (like Claude, Cursor, and others) to securely manage and analyze personal finances through natural language.
31
+
32
+ Released under the MIT License and designed to be extended with additional finance tools.
33
+
34
+ ---
35
+
36
+ ## 🔍 Overview
37
+
38
+ Unlike standard cloud-based personal finance apps, **Finance Intelligence MCP** keeps all financial data under your absolute control.
39
+
40
+ * **No Accounts / Subscriptions**: Direct connection to your private database with zero SaaS dependencies.
41
+ * **Zero Telemetry**: Your financial logs never go to external analytic APIs.
42
+ * **Full AI Context**: Your AI assistant can query history, check budget status, make charts, and answer financial questions instantly.
43
+
44
+
45
+
46
+ ---
47
+
48
+ ## 🛠️ Architecture
49
+
50
+ ```
51
+ ┌────────────────────────────────┐
52
+ │ MCP Client │
53
+ │ (Claude Desktop, Cursor, etc) │
54
+ └───────────────┬────────────────┘
55
+
56
+ │ (STDIO Transport Protocol)
57
+
58
+ ┌────────────────────────────────┐
59
+ │ Finance Intelligence Server │
60
+ │ (Local) │
61
+ └───────────────┬────────────────┘
62
+
63
+ │ (Direct SQL Pool Connection)
64
+
65
+ ┌────────────────────────────────┐
66
+ │ PostgreSQL Database │
67
+ │ (Supabase, Local, RDS, etc) │
68
+ └────────────────────────────────┘
69
+ ```
70
+
71
+ ---
72
+
73
+ ## ✨ Features
74
+
75
+ * **Expense Operations**: Create, view, edit, and bulk-delete expense entries.
76
+ * **Multi-level Budgeting**: Set limits for `overall` spending, specific `category` thresholds, or down to nested `subcategory` targets.
77
+ * **Analytics Breakdowns**: Aggregate expenses by category, subcategory, notes, or dates over weekly/monthly/yearly time blocks.
78
+ * **Visual Charts**: Exposes tools that generate Matplotlib line/bar charts of historical spending automatically.
79
+ * **Excel Spreadsheet Export**: Truncates long list responses and generates download-ready Excel spreadsheets for massive datasets.
80
+ * **Financial Health Scoring**: Calculates a deterministic financial health score grade based on 6 core personal finance KPIs.
81
+
82
+ ---
83
+
84
+ ## ⚙️ Requirements & Compatibility
85
+
86
+ * **Transport**: `stdio`
87
+ * **Supported Platforms**:
88
+ * Windows
89
+ * macOS
90
+ * Linux
91
+ * **Python Compatibility**: Python 3.10, 3.11, and 3.12 (Tested)
92
+ * **Database**: PostgreSQL 12+ (e.g. Supabase, RDS, or local Postgres)
93
+ * **Supported Clients**:
94
+ * Claude Desktop
95
+ * Cursor
96
+ * *Compatible with any MCP client supporting stdio.*
97
+
98
+ ---
99
+
100
+ ## 💾 Installation & Setup
101
+
102
+ ### 1. Quick Setup (Shortcut)
103
+ If you have `uv` installed, get started in 2 lines:
104
+ ```bash
105
+ git clone https://github.com/Ronit-019/Finance-Intelligence-MCP.git
106
+ cd Finance-Intelligence-MCP
107
+ uv sync
108
+ ```
109
+
110
+ ### 2. Detailed Installation
111
+
112
+ #### Step A: Get a PostgreSQL Connection URL
113
+ The easiest, free option is **Supabase**:
114
+ 1. Go to [Supabase](https://supabase.com/) and create a free project.
115
+ 2. Go to **Project Settings** (gear icon) > **Database**.
116
+ 3. Under **Connection string**, select **URI** and copy the string.
117
+ * *Example*: `postgresql://postgres.[your-project-ref]:[your-password]@aws-0-us-east-1.pooler.supabase.com:5432/postgres`
118
+ * *(Replace `[your-password]` with your database password).*
119
+
120
+ #### Step B: Clone the Repository
121
+ Clone the codebase to a directory on your machine:
122
+ ```bash
123
+ git clone https://github.com/Ronit-019/Finance-Intelligence-MCP.git
124
+ cd Finance-Intelligence-MCP
125
+ ```
126
+ Copy the absolute path of this directory (e.g. `C:/Users/Admin/Desktop/Finance-Intelligence-MCP`).
127
+ *Note: Always use forward slashes (`/`) for paths in JSON configs.*
128
+
129
+ #### Step C: Install Dependencies
130
+ If you do not have `uv` installed, install from the project metadata:
131
+ ```bash
132
+ pip install -e .
133
+ ```
134
+
135
+ ---
136
+
137
+ ### 3. Client Integration
138
+
139
+ #### Claude Desktop Setup
140
+ 1. Open your Claude configuration file (`claude_desktop_config.json`):
141
+ * **Windows**: Press `Win + R`, paste `%APPDATA%\Claude\claude_desktop_config.json` and press Enter.
142
+ * **macOS**: Paste `~/Library/Application Support/Claude/claude_desktop_config.json` in Finder's Go to Folder.
143
+ 2. Add this entry to `mcpServers`:
144
+
145
+ ```json
146
+ {
147
+ "mcpServers": {
148
+ "finance-intelligence": {
149
+ "command": "uv",
150
+ "args": [
151
+ "--directory",
152
+ "REPLACE_WITH_ABSOLUTE_PATH_TO_CLONED_DIRECTORY",
153
+ "run",
154
+ "python",
155
+ "main.py"
156
+ ],
157
+ "env": {
158
+ "DATABASE_URL": "REPLACE_WITH_YOUR_SUPABASE_CONNECTION_STRING"
159
+ }
160
+ }
161
+ }
162
+ }
163
+ ```
164
+ 3. Save and completely **restart Claude Desktop**.
165
+
166
+ #### Cursor IDE Setup
167
+ 1. Go to **Settings** > **Features** > **MCP**.
168
+ 2. Click **+ Add New MCP Server**:
169
+ * **Name**: `Finance Intelligence`
170
+ * **Type**: `command`
171
+ * **Command**:
172
+ ```bash
173
+ uv --directory "REPLACE_WITH_ABSOLUTE_PATH_TO_CLONED_DIRECTORY" run python main.py
174
+ ```
175
+ 3. Click **+ Add Env Var**:
176
+ * **Key**: `DATABASE_URL`
177
+ * **Value**: `REPLACE_WITH_YOUR_SUPABASE_CONNECTION_STRING`
178
+ 4. Click **Save** and refresh.
179
+
180
+ ---
181
+
182
+ ## 💬 Example Prompts
183
+
184
+ Once configured, try talking to your AI assistant:
185
+ * *"Add ₹250 for lunch today under food."*
186
+ * *"Show my spending breakdown this month."*
187
+ * *"Generate an Excel report of all my expenses between May and July."*
188
+ * *"Am I exceeding my monthly budget limit?"*
189
+ * *"How much did I spend on dining out this week?"*
190
+ * *"Calculate my monthly financial health score and give me feedback."*
191
+ * *"Generate a spending chart for the last 30 days."*
192
+
193
+ ---
194
+
195
+ ## 🛠️ Available Tools
196
+
197
+ The server registers 12 core tools on the client:
198
+
199
+ | Tool Name | Parameters | Description |
200
+ | :--- | :--- | :--- |
201
+ | `add_expense` | `date`, `amount`, `category`, `subcategory`, `note` | Inserts a new expense transaction. |
202
+ | `list_expenses` | `start_date`, `end_date` | Lists transactions, exports to Excel if count > 50. |
203
+ | `expense_breakdown`| `start_date`, `end_date`, `group_by`, `breakdown`, `category`, `subcategory` | Aggregates spending sums and counts. |
204
+ | `delete_expenses` | `expense_ids`, `start_date`, `end_date`, `category`, `subcategory` | Deletes expenses matching filters. |
205
+ | `update_expenses` | `expense_ids`, `filter_...`, `date`, `amount`, `category`, `subcategory`, `note` | Edits expense records. |
206
+ | `create_budget` | `budget_type`, `amount`, `period`, `start_date`, `end_date`, `category`, `subcategory`, `budgets` | Registers new spending limits. |
207
+ | `list_budgets` | `budget_type`, `category`, `subcategory`, `period` | Returns registered budgets. |
208
+ | `update_budgets` | `budget_ids`, `filter_...`, `budget_type`, `amount`, `period`, `start_date`, `end_date`, `category`, `subcategory` | Modifies active budgets. |
209
+ | `delete_budgets` | `budget_ids`, `start_date`, `end_date`, `budget_type`, `category`, `subcategory`, `period` | Deletes target budget limits. |
210
+ | `compare_budget_vs_expenses` | `reference_date`, `budget_type`, `category`, `subcategory`, `period` | Compares budget vs actual spending. |
211
+ | `expense_summary` | `period`, `group_by`, `category`, `subcategory`, `start_date`, `end_date` | Generates a Matplotlib line/bar chart. |
212
+ | `financial_health_score` | `reference_month` | Evaluates 6 key personal finance indicators. |
213
+
214
+ ---
215
+
216
+ ## 💡 Troubleshooting
217
+
218
+ ### 1. `uv: command not found`
219
+ If the client cannot locate `uv`, update your config file to run standard Python:
220
+ * Ensure you ran `pip install -e .` inside the repository.
221
+ * Update config:
222
+ ```json
223
+ "finance-intelligence": {
224
+ "command": "python",
225
+ "args": [
226
+ "REPLACE_WITH_ABSOLUTE_PATH_TO_CLONED_DIRECTORY/main.py"
227
+ ],
228
+ "env": {
229
+ "DATABASE_URL": "REPLACE_WITH_YOUR_SUPABASE_CONNECTION_STRING"
230
+ }
231
+ }
232
+ ```
233
+
234
+ ### 2. Invalid `DATABASE_URL` / PostgreSQL Connection Errors
235
+ * Make sure you replaced `[your-password]` with your actual database password in the Supabase URI string.
236
+ * Ensure there are no surrounding spaces or special characters in the URL string.
237
+ * Verify your Supabase instance is active and not paused.
238
+
239
+ ### 3. Claude Not Detecting the Server
240
+ * Double check that the folder paths in `claude_desktop_config.json` use **forward slashes** (`/`), even on Windows.
241
+ * Check the logs at `%APPDATA%\Claude\logs\mcp*.log` (Windows) or `~/Library/Logs/Claude/mcp*.log` (macOS) to see the exact startup error.
242
+
243
+ ---
244
+
245
+ ## 📁 Repository Structure
246
+ ```
247
+ ├── src/ # Helper packages
248
+ │ ├── __init__.py
249
+ │ ├── budget.py # Budget database operations
250
+ │ ├── analytics.py # Breakdown aggregations & Matplotlib routines
251
+ │ └── health.py # KPIs and financial health calculator
252
+ ├── main.py # FastMCP Server application
253
+ ├── categories.json # Category mapping catalog
254
+ ├── pyproject.toml # Dependencies
255
+ ├── LICENSE # MIT License file
256
+ └── README.md # This file
257
+ ```
258
+
259
+ ---
260
+
261
+ ## 🤝 Contributing
262
+ Contributions, bug reports, and feature requests are welcome!
263
+ Please open an issue before submitting major changes or pull requests.
264
+
265
+ ---
266
+
267
+ ## 📄 License
268
+ This project is licensed under the [MIT License](LICENSE) - see the LICENSE file for details.
269
+
270
+ ---
271
+
272
+ ## 👨‍💻 Author
273
+
274
+ Ronit Rajput
275
+
276
+ - Portfolio: https://ronitportfolio-seven.vercel.app/assistant
277
+ - GitHub: https://github.com/Ronit-019
278
+ - LinkedIn: https://www.linkedin.com/in/ronit-rajput/
@@ -0,0 +1,9 @@
1
+ __init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ analytics.py,sha256=f21JFRE8OITbmAvuxj1VU17dpsZTUZX6lwDi_qe3OlE,6642
3
+ budget.py,sha256=srXQBsSuCT13s3U3mfHT9JToxhJFyc_3y-Cy2o8abWY,19575
4
+ health.py,sha256=RX_6xvyIlsM5DUnCGTd8nXsooOnrg59qfM6NXb6lhpk,11916
5
+ finance_intelligence_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=kKF6ju9nT16naoSoTUUioKiizkzhtep70azqhqbAeHM,1062
6
+ finance_intelligence_mcp-0.1.0.dist-info/METADATA,sha256=Dzqi4AYhuWkDasL693Z1RefDYt0rquisb3B817XRC-E,11523
7
+ finance_intelligence_mcp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ finance_intelligence_mcp-0.1.0.dist-info/top_level.txt,sha256=IiCm945orQ2l_gY2nzRrDjSGjNjjQFV71NUN24nZAXA,33
9
+ finance_intelligence_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ronit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ __init__
2
+ analytics
3
+ budget
4
+ health
health.py ADDED
@@ -0,0 +1,299 @@
1
+ import os
2
+ from datetime import date as pydate, timedelta
3
+ import statistics
4
+
5
+ # Define category lists
6
+ ESSENTIALS = {'food', 'transport', 'housing', 'utilities', 'health', 'family_kids', 'home', 'taxes', 'finance_fees'}
7
+ DISCRETIONARY = {'entertainment', 'shopping', 'travel', 'subscriptions', 'personal_care', 'gifts_donations', 'misc', 'business', 'investments'}
8
+
9
+ async def financial_health_score_impl(conn, user_id: int, reference_month: str = None) -> dict:
10
+ # 1. Resolve date targets
11
+ if reference_month:
12
+ try:
13
+ parts = reference_month.split("-")
14
+ year = int(parts[0])
15
+ month = int(parts[1])
16
+ start_date = pydate(year, month, 1)
17
+ except Exception as e:
18
+ return {
19
+ "status": "error",
20
+ "message": f"Invalid reference_month format (expected YYYY-MM): {str(e)}"
21
+ }
22
+ else:
23
+ today = pydate.today()
24
+ year = today.year
25
+ month = today.month
26
+ start_date = pydate(year, month, 1)
27
+
28
+ # End of target month
29
+ if month == 12:
30
+ end_date = pydate(year, 12, 31)
31
+ else:
32
+ end_date = pydate(year, month + 1, 1) - timedelta(days=1)
33
+
34
+ # Previous Month M-1
35
+ if month == 1:
36
+ prev_start = pydate(year - 1, 12, 1)
37
+ prev_end = pydate(year - 1, 12, 31)
38
+ else:
39
+ prev_start = pydate(year, month - 1, 1)
40
+ prev_end = pydate(year, month, 1) - timedelta(days=1)
41
+
42
+ # Month M-2
43
+ if month == 1:
44
+ prev2_start = pydate(year - 1, 11, 1)
45
+ prev2_end = pydate(year - 1, 11, 30)
46
+ elif month == 2:
47
+ prev2_start = pydate(year - 1, 12, 1)
48
+ prev2_end = pydate(year - 1, 12, 31)
49
+ else:
50
+ prev2_start = pydate(year, month - 2, 1)
51
+ prev2_end = pydate(year, month - 1, 1) - timedelta(days=1)
52
+
53
+ # 2. Database Queries
54
+
55
+ # 2.1 Get active budgets
56
+ budgets_rows = await conn.fetch(
57
+ """
58
+ SELECT id, budget_type, category, subcategory, amount, period, start_date, end_date
59
+ FROM budgets
60
+ WHERE user_id = $1 AND start_date <= $2 AND end_date >= $3
61
+ """,
62
+ user_id, end_date, start_date
63
+ )
64
+
65
+ # 2.2 Category spent breakdown for target month
66
+ cat_rows = await conn.fetch(
67
+ """
68
+ SELECT category, COALESCE(SUM(amount), 0.0) as total_amount
69
+ FROM expenses
70
+ WHERE user_id = $1 AND date BETWEEN $2 AND $3
71
+ GROUP BY category
72
+ """,
73
+ user_id, start_date, end_date
74
+ )
75
+
76
+ # 2.3 Max single transaction in target month
77
+ max_expense = await conn.fetchval(
78
+ """
79
+ SELECT COALESCE(MAX(amount), 0.0)
80
+ FROM expenses
81
+ WHERE user_id = $1 AND date BETWEEN $2 AND $3
82
+ """,
83
+ user_id, start_date, end_date
84
+ )
85
+
86
+ # 2.4 Total spending for M, M-1, M-2
87
+ monthly_spends = {}
88
+ for label, s, e in [("M", start_date, end_date), ("M-1", prev_start, prev_end), ("M-2", prev2_start, prev2_end)]:
89
+ val = await conn.fetchval(
90
+ "SELECT COALESCE(SUM(amount), 0.0) FROM expenses WHERE user_id = $1 AND date BETWEEN $2 AND $3",
91
+ user_id, s, e
92
+ )
93
+ monthly_spends[label] = val
94
+
95
+ # 3. KPI Scoring
96
+
97
+ breakdown = {}
98
+ reasons = {}
99
+
100
+ # 3.1 Budget Adherence
101
+ exceeded_count = 0
102
+ total_overshoot = 0.0
103
+ total_active_budgets = len(budgets_rows)
104
+ overall_budget_total = 0.0
105
+
106
+ for b in budgets_rows:
107
+ b_type = b["budget_type"]
108
+ b_cat = b["category"]
109
+ b_sub = b["subcategory"]
110
+ b_amount = b["amount"]
111
+ overall_budget_total += b_amount
112
+
113
+ # Find matching expenses sum
114
+ exp_query = "SELECT COALESCE(SUM(amount), 0.0) FROM expenses WHERE user_id = $1 AND date BETWEEN $2 AND $3"
115
+ exp_params = [user_id, b["start_date"], b["end_date"]]
116
+ exp_param_idx = 4
117
+ if b_type == "category":
118
+ exp_query += f" AND category = ${exp_param_idx}"
119
+ exp_params.append(b_cat)
120
+ elif b_type == "subcategory":
121
+ exp_query += f" AND category = ${exp_param_idx} AND subcategory = ${exp_param_idx+1}"
122
+ exp_params.extend([b_cat, b_sub])
123
+
124
+ spent = await conn.fetchval(exp_query, *exp_params)
125
+ if spent > b_amount:
126
+ exceeded_count += 1
127
+ overshoot_pct = (spent - b_amount) / b_amount if b_amount > 0 else 1.0
128
+ total_overshoot += overshoot_pct
129
+
130
+ if total_active_budgets == 0:
131
+ breakdown["budget_adherence"] = 7
132
+ reasons["budget_adherence"] = "No active budgets are currently configured. Create a budget to track spending limits."
133
+ else:
134
+ score = 10.0
135
+ deduct_freq = (exceeded_count / total_active_budgets) * 5.0
136
+ score -= deduct_freq
137
+
138
+ if exceeded_count > 0:
139
+ avg_overshoot = total_overshoot / exceeded_count
140
+ if avg_overshoot > 0.50:
141
+ score -= 5.0
142
+ elif avg_overshoot > 0.20:
143
+ score -= 3.0
144
+ else:
145
+ score -= 1.0
146
+ score = max(0, round(score))
147
+ breakdown["budget_adherence"] = score
148
+ if exceeded_count == 0:
149
+ reasons["budget_adherence"] = "Excellent! You stayed within all defined budget limits."
150
+ else:
151
+ reasons["budget_adherence"] = f"Exceeded {exceeded_count}/{total_active_budgets} active budgets. Average overspend percentage was {round((total_overshoot / exceeded_count) * 100, 1)}%."
152
+
153
+ # 3.2 Expense Stability
154
+ totals = [monthly_spends["M-2"], monthly_spends["M-1"], monthly_spends["M"]]
155
+ mean_spend = statistics.mean(totals)
156
+ std_spend = statistics.stdev(totals) if len(totals) > 1 else 0.0
157
+ cv = (std_spend / mean_spend) if mean_spend > 0 else 0.0
158
+
159
+ # Neutral default if no spends are logged across history
160
+ if mean_spend == 0:
161
+ breakdown["expense_stability"] = 7
162
+ reasons["expense_stability"] = "Insufficient spending records to determine stability history."
163
+ else:
164
+ if cv <= 0.15:
165
+ score = 10
166
+ msg = "Highly stable: Month-to-month spending remains highly consistent."
167
+ elif cv <= 0.30:
168
+ score = 8
169
+ msg = "Stable: Spending exhibits standard minor fluctuations."
170
+ elif cv <= 0.50:
171
+ score = 5
172
+ msg = "Moderate: Spending shows noticeable volatility between periods."
173
+ else:
174
+ score = 2
175
+ msg = "Erratic: Spending fluctuates dramatically between months. Plan expenses in advance."
176
+ breakdown["expense_stability"] = score
177
+ reasons["expense_stability"] = f"{msg} (Coefficient of Variation: {round(cv, 3)})"
178
+
179
+ # 3.3 Savings Capacity
180
+ target_spent = monthly_spends["M"]
181
+ if overall_budget_total == 0:
182
+ breakdown["savings_capacity"] = 7
183
+ reasons["savings_capacity"] = "Configure budget limits to calculate savings ratio metrics."
184
+ else:
185
+ savings_ratio = (overall_budget_total - target_spent) / overall_budget_total
186
+ if savings_ratio >= 0.20:
187
+ score = 10
188
+ msg = f"Superb! You saved {round(savings_ratio * 100, 1)}% of your budgeted limit."
189
+ elif savings_ratio >= 0.10:
190
+ score = 8
191
+ msg = f"Good. You saved {round(savings_ratio * 100, 1)}% of your budgeted limit."
192
+ elif savings_ratio >= 0.00:
193
+ score = 5
194
+ msg = f"Marginal. You saved {round(savings_ratio * 100, 1)}% of your budgeted limit."
195
+ else:
196
+ score = 0
197
+ msg = f"Warning: Spend exceeded budget totals by {round(abs(savings_ratio) * 100, 1)}%."
198
+ breakdown["savings_capacity"] = score
199
+ reasons["savings_capacity"] = msg
200
+
201
+ # 3.4 Category Balance
202
+ essential_spend = 0.0
203
+ discretionary_spend = 0.0
204
+ for r in cat_rows:
205
+ cat_key = r["category"].lower()
206
+ amt = r["total_amount"]
207
+ if cat_key in DISCRETIONARY:
208
+ discretionary_spend += amt
209
+ else:
210
+ essential_spend += amt
211
+
212
+ total_cat_spend = essential_spend + discretionary_spend
213
+ if total_cat_spend == 0:
214
+ breakdown["category_balance"] = 10
215
+ reasons["category_balance"] = "No transaction records exist for this month."
216
+ else:
217
+ discretionary_ratio = discretionary_spend / total_cat_spend
218
+ if discretionary_ratio <= 0.30:
219
+ score = 10
220
+ msg = "Ideal allocation: Discretionary wants make up 30% or less of total spending."
221
+ elif discretionary_ratio <= 0.50:
222
+ score = 7
223
+ msg = "Satisfactory: Discretionary categories compose between 30% and 50% of your expenses."
224
+ elif discretionary_ratio <= 0.70:
225
+ score = 4
226
+ msg = "Elevated wants: Discretionary categories consume 50% to 70% of expenses. Review subscriptions/shopping."
227
+ else:
228
+ score = 0
229
+ msg = "Critical: Over 70% of monthly spending was dedicated to discretionary desires."
230
+ breakdown["category_balance"] = score
231
+ reasons["category_balance"] = f"{msg} (Discretionary ratio: {round(discretionary_ratio * 100, 1)}%)"
232
+
233
+ # 3.5 Large Expense Ratio
234
+ if target_spent == 0:
235
+ breakdown["large_expense_ratio"] = 10
236
+ reasons["large_expense_ratio"] = "No transactions exist to evaluate."
237
+ else:
238
+ le_ratio = max_expense / target_spent
239
+ if le_ratio <= 0.15:
240
+ score = 10
241
+ msg = "Evenly distributed: No single transaction dominates your monthly expenses."
242
+ elif le_ratio <= 0.30:
243
+ score = 8
244
+ msg = "Healthy: Single largest transaction constitutes a moderate portion of spending."
245
+ elif le_ratio <= 0.50:
246
+ score = 5
247
+ msg = "High concentration: Single transaction accounted for over 30% of total spending."
248
+ else:
249
+ score = 2
250
+ msg = "Extreme concentration: Single transaction consumed more than 50% of the entire monthly spent."
251
+ breakdown["large_expense_ratio"] = score
252
+ reasons["large_expense_ratio"] = f"{msg} (Largest expense ratio: {round(le_ratio * 100, 1)}%)"
253
+
254
+ # 3.6 Spending Trend
255
+ curr_m = monthly_spends["M"]
256
+ prev_m = monthly_spends["M-1"]
257
+
258
+ if prev_m == 0:
259
+ breakdown["spending_trend"] = 7
260
+ reasons["spending_trend"] = "No previous month records exist to check trend direction."
261
+ else:
262
+ if curr_m < prev_m:
263
+ score = 10
264
+ msg = f"Positive progress! Spending decreased by {round(((prev_m - curr_m) / prev_m) * 100, 1)}% MoM."
265
+ elif abs(curr_m - prev_m) / prev_m <= 0.05:
266
+ score = 7
267
+ msg = "Stable trend: Spending remained virtually unchanged from the previous month."
268
+ else:
269
+ pct_inc = ((curr_m - prev_m) / prev_m) * 100
270
+ if pct_inc <= 15.0:
271
+ score = 5
272
+ msg = f"Minor increase: Spending rose by {round(pct_inc, 1)}% compared to last month."
273
+ else:
274
+ score = 2
275
+ msg = f"Major increase: Spending grew significantly by {round(pct_inc, 1)}% MoM."
276
+ breakdown["spending_trend"] = score
277
+ reasons["spending_trend"] = msg
278
+
279
+ # 4. Overall score
280
+ total_raw = sum(breakdown.values())
281
+ health_score = int((total_raw / 60) * 100)
282
+
283
+ if health_score >= 85:
284
+ grade = "Excellent"
285
+ elif health_score >= 70:
286
+ grade = "Good"
287
+ elif health_score >= 50:
288
+ grade = "Fair"
289
+ else:
290
+ grade = "Poor"
291
+
292
+ return {
293
+ "status": "ok",
294
+ "reference_month": f"{year}-{month:02d}",
295
+ "financial_health_score": health_score,
296
+ "grade": grade,
297
+ "breakdown": breakdown,
298
+ "reasons": reasons
299
+ }