csv Data_4
Browse files- app.py +370 -109
- fiscal.py +1 -1
- plaid_client.py +2 -6
app.py
CHANGED
|
@@ -75,6 +75,310 @@ def health():
|
|
| 75 |
return {"status": "ok", "service": "FISCAL"}
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
def get_cached_financial_context(access_token: str, user_id: str) -> str:
|
| 80 |
now = time.time()
|
|
@@ -1185,9 +1489,7 @@ async def trial_yearsphere_data(request: Request):
|
|
| 1185 |
"current_month_index": current_idx,
|
| 1186 |
}
|
| 1187 |
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
@app.post("/csv/chart_data")
|
| 1192 |
async def csv_chart_data(request: Request):
|
| 1193 |
"""Chart data from user-uploaded CSV transactions."""
|
|
@@ -1344,70 +1646,12 @@ async def csv_prescription_activity(request: Request):
|
|
| 1344 |
|
| 1345 |
@app.post("/csv/cashflow/data")
|
| 1346 |
async def csv_cashflow_data(request: Request):
|
| 1347 |
-
"""Cash flow
|
| 1348 |
body = await request.json()
|
| 1349 |
transactions = body.get("transactions", [])
|
| 1350 |
-
|
| 1351 |
-
|
| 1352 |
-
|
| 1353 |
-
|
| 1354 |
-
end_date = date.today()
|
| 1355 |
-
start_date = end_date - timedelta(days=30)
|
| 1356 |
-
|
| 1357 |
-
# Aggregate by category
|
| 1358 |
-
category_totals = defaultdict(lambda: {'total': 0, 'count': 0, 'merchants': defaultdict(float)})
|
| 1359 |
-
income_total = 0
|
| 1360 |
-
expense_total = 0
|
| 1361 |
-
|
| 1362 |
-
for txn in transactions:
|
| 1363 |
-
txn_date_str = txn.get('date', '')
|
| 1364 |
-
try:
|
| 1365 |
-
txn_date = date.fromisoformat(txn_date_str)
|
| 1366 |
-
except (ValueError, TypeError):
|
| 1367 |
-
continue
|
| 1368 |
-
|
| 1369 |
-
if txn_date < start_date or txn_date > end_date:
|
| 1370 |
-
continue
|
| 1371 |
-
|
| 1372 |
-
amount = txn.get('amount', 0)
|
| 1373 |
-
name = txn.get('name', 'Unknown')
|
| 1374 |
-
category = txn.get('personal_finance_category', {}).get('primary', 'GENERAL_MERCHANDISE')
|
| 1375 |
-
|
| 1376 |
-
# Skip transfers
|
| 1377 |
-
if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
|
| 1378 |
-
continue
|
| 1379 |
-
|
| 1380 |
-
if amount < 0:
|
| 1381 |
-
income_total += abs(amount)
|
| 1382 |
-
else:
|
| 1383 |
-
expense_total += amount
|
| 1384 |
-
category_totals[category]['total'] += amount
|
| 1385 |
-
category_totals[category]['count'] += 1
|
| 1386 |
-
category_totals[category]['merchants'][name] += amount
|
| 1387 |
-
|
| 1388 |
-
# Build categories array with merchants
|
| 1389 |
-
categories = []
|
| 1390 |
-
for cat, data in category_totals.items():
|
| 1391 |
-
merchants = [
|
| 1392 |
-
{'name': m_name, 'amount': round(m_amount, 2)}
|
| 1393 |
-
for m_name, m_amount in sorted(data['merchants'].items(), key=lambda x: -x[1])[:10]
|
| 1394 |
-
]
|
| 1395 |
-
categories.append({
|
| 1396 |
-
'category': cat,
|
| 1397 |
-
'total': round(data['total'], 2),
|
| 1398 |
-
'count': data['count'],
|
| 1399 |
-
'merchants': merchants,
|
| 1400 |
-
})
|
| 1401 |
-
|
| 1402 |
-
categories.sort(key=lambda x: -x['total'])
|
| 1403 |
-
|
| 1404 |
-
return {
|
| 1405 |
-
'categories': categories,
|
| 1406 |
-
'income_total': round(income_total, 2),
|
| 1407 |
-
'expense_total': round(expense_total, 2),
|
| 1408 |
-
'remaining': round(income_total - expense_total, 2),
|
| 1409 |
-
}
|
| 1410 |
-
|
| 1411 |
|
| 1412 |
@app.post("/csv/cashflow/opinion")
|
| 1413 |
async def csv_cashflow_opinion(request: Request):
|
|
@@ -1448,56 +1692,73 @@ Keep it under 200 words. Warm, doctor-patient tone. Prose only, no lists."""
|
|
| 1448 |
return StreamingResponse(generate(), media_type="text/event-stream")
|
| 1449 |
|
| 1450 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1451 |
@app.post("/csv/yearsphere/data")
|
| 1452 |
async def csv_yearsphere_data(request: Request):
|
| 1453 |
-
"""Year
|
|
|
|
|
|
|
| 1454 |
body = await request.json()
|
| 1455 |
transactions = body.get("transactions", [])
|
| 1456 |
|
| 1457 |
-
|
| 1458 |
-
|
| 1459 |
|
| 1460 |
-
|
| 1461 |
-
|
|
|
|
|
|
|
|
|
|
| 1462 |
|
| 1463 |
-
|
| 1464 |
-
|
| 1465 |
-
|
| 1466 |
-
|
| 1467 |
-
|
| 1468 |
-
|
| 1469 |
-
|
| 1470 |
-
|
| 1471 |
-
|
| 1472 |
-
category = txn.get('personal_finance_category', {}).get('primary', '')
|
| 1473 |
-
|
| 1474 |
-
# Skip transfers
|
| 1475 |
-
if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
|
| 1476 |
-
continue
|
| 1477 |
-
|
| 1478 |
-
if amount < 0:
|
| 1479 |
-
monthly[month_key]['income'] += abs(amount)
|
| 1480 |
-
else:
|
| 1481 |
-
monthly[month_key]['expenses'] += amount
|
| 1482 |
-
|
| 1483 |
-
monthly[month_key]['transactions'].append({
|
| 1484 |
-
'date': txn_date_str,
|
| 1485 |
-
'name': txn.get('name', ''),
|
| 1486 |
-
'amount': amount,
|
| 1487 |
-
'category': category,
|
| 1488 |
-
})
|
| 1489 |
|
| 1490 |
-
|
| 1491 |
-
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
|
| 1495 |
-
'month': month_key,
|
| 1496 |
-
'income': round(data['income'], 2),
|
| 1497 |
-
'expenses': round(data['expenses'], 2),
|
| 1498 |
-
'net': round(data['income'] - data['expenses'], 2),
|
| 1499 |
-
'transaction_count': len(data['transactions']),
|
| 1500 |
-
'top_transactions': sorted(data['transactions'], key=lambda x: abs(x['amount']), reverse=True)[:5],
|
| 1501 |
-
})
|
| 1502 |
|
| 1503 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
return {"status": "ok", "service": "FISCAL"}
|
| 76 |
|
| 77 |
|
| 78 |
+
def _build_cashflow_data(all_transactions: list) -> dict:
|
| 79 |
+
"""
|
| 80 |
+
Shared cash flow data builder.
|
| 81 |
+
Returns income + expense breakdown grouped by category and merchant.
|
| 82 |
+
Used by both /trial/cashflow/data and /csv/cashflow/data.
|
| 83 |
+
"""
|
| 84 |
+
from datetime import date, timedelta
|
| 85 |
+
from collections import defaultdict
|
| 86 |
+
import re
|
| 87 |
+
|
| 88 |
+
end_date = date.today()
|
| 89 |
+
start_date = end_date - timedelta(days=30)
|
| 90 |
+
|
| 91 |
+
income_total = 0.0
|
| 92 |
+
category_data = defaultdict(lambda: {
|
| 93 |
+
"total": 0.0,
|
| 94 |
+
"merchants": defaultdict(lambda: {"total": 0.0, "count": 0, "transactions": []}),
|
| 95 |
+
"count": 0,
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
for txn in all_transactions:
|
| 99 |
+
txn_date_val = txn.get('date')
|
| 100 |
+
if isinstance(txn_date_val, str):
|
| 101 |
+
try:
|
| 102 |
+
txn_date = date.fromisoformat(txn_date_val)
|
| 103 |
+
except ValueError:
|
| 104 |
+
continue
|
| 105 |
+
else:
|
| 106 |
+
txn_date = txn_date_val
|
| 107 |
+
|
| 108 |
+
if txn_date < start_date or txn_date > end_date:
|
| 109 |
+
continue
|
| 110 |
+
|
| 111 |
+
amount = txn['amount']
|
| 112 |
+
name = txn.get('name', 'Unknown')
|
| 113 |
+
category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
|
| 114 |
+
|
| 115 |
+
if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
name_lower = name.lower()
|
| 119 |
+
is_income = amount < 0 or (
|
| 120 |
+
category == 'LOAN_PAYMENTS' and
|
| 121 |
+
any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages'])
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
if is_income:
|
| 125 |
+
income_total += abs(amount)
|
| 126 |
+
continue
|
| 127 |
+
|
| 128 |
+
if category == 'LOAN_PAYMENTS':
|
| 129 |
+
continue
|
| 130 |
+
|
| 131 |
+
normalized_merchant = re.sub(r'[0-9#]+', '', name).strip()
|
| 132 |
+
normalized_merchant = re.sub(r'\s+', ' ', normalized_merchant).upper() or name
|
| 133 |
+
|
| 134 |
+
category_data[category]["total"] += amount
|
| 135 |
+
category_data[category]["count"] += 1
|
| 136 |
+
category_data[category]["merchants"][normalized_merchant]["total"] += amount
|
| 137 |
+
category_data[category]["merchants"][normalized_merchant]["count"] += 1
|
| 138 |
+
category_data[category]["merchants"][normalized_merchant]["transactions"].append({
|
| 139 |
+
"date": str(txn_date),
|
| 140 |
+
"amount": round(amount, 2),
|
| 141 |
+
"name": name,
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
categories = []
|
| 145 |
+
for cat_name, cat_info in category_data.items():
|
| 146 |
+
merchants = []
|
| 147 |
+
for merchant_name, merchant_info in cat_info["merchants"].items():
|
| 148 |
+
merchants.append({
|
| 149 |
+
"name": merchant_name,
|
| 150 |
+
"display_name": merchant_info["transactions"][0]["name"] if merchant_info["transactions"] else merchant_name,
|
| 151 |
+
"total": round(merchant_info["total"], 2),
|
| 152 |
+
"count": merchant_info["count"],
|
| 153 |
+
"transactions": sorted(
|
| 154 |
+
merchant_info["transactions"],
|
| 155 |
+
key=lambda x: x["date"],
|
| 156 |
+
reverse=True
|
| 157 |
+
)[:10],
|
| 158 |
+
})
|
| 159 |
+
merchants.sort(key=lambda x: -x["total"])
|
| 160 |
+
|
| 161 |
+
readable_name = cat_name.replace("_", " ").title()
|
| 162 |
+
|
| 163 |
+
categories.append({
|
| 164 |
+
"name": readable_name,
|
| 165 |
+
"raw_name": cat_name,
|
| 166 |
+
"total": round(cat_info["total"], 2),
|
| 167 |
+
"count": cat_info["count"],
|
| 168 |
+
"merchants": merchants,
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
categories.sort(key=lambda x: -x["total"])
|
| 172 |
+
|
| 173 |
+
return {
|
| 174 |
+
"income_total": round(income_total, 2),
|
| 175 |
+
"categories": categories,
|
| 176 |
+
"date_range": {
|
| 177 |
+
"start": str(start_date),
|
| 178 |
+
"end": str(end_date),
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
def _build_yearsphere_data(all_transactions: list, recurring_data: dict) -> dict:
|
| 183 |
+
"""
|
| 184 |
+
Shared Year Sphere data builder.
|
| 185 |
+
Takes raw transactions + recurring analysis, returns full sphere data shape.
|
| 186 |
+
Used by both /trial/yearsphere/data and /csv/yearsphere/data.
|
| 187 |
+
"""
|
| 188 |
+
from datetime import date
|
| 189 |
+
from collections import defaultdict
|
| 190 |
+
from calendar import monthrange
|
| 191 |
+
|
| 192 |
+
recurring_expenses = recurring_data.get("recurring_expenses", [])
|
| 193 |
+
recurring_income = recurring_data.get("recurring_income", [])
|
| 194 |
+
projected_events = recurring_data.get("projected_events", [])
|
| 195 |
+
|
| 196 |
+
today = date.today()
|
| 197 |
+
|
| 198 |
+
# Build the month window: 8 months back through 3 months forward
|
| 199 |
+
window = []
|
| 200 |
+
for offset in range(-8, 4):
|
| 201 |
+
target_month = today.month + offset
|
| 202 |
+
target_year = today.year
|
| 203 |
+
while target_month < 1:
|
| 204 |
+
target_month += 12
|
| 205 |
+
target_year -= 1
|
| 206 |
+
while target_month > 12:
|
| 207 |
+
target_month -= 12
|
| 208 |
+
target_year += 1
|
| 209 |
+
window.append((target_year, target_month))
|
| 210 |
+
|
| 211 |
+
# Build per-month recurring index
|
| 212 |
+
recurring_by_month = defaultdict(list)
|
| 213 |
+
seen_month_names = set()
|
| 214 |
+
|
| 215 |
+
def add_recurring_occurrence(year, month, day, name, amount, is_income):
|
| 216 |
+
dedupe_key = (year, month, name.upper().strip(), day)
|
| 217 |
+
if dedupe_key in seen_month_names:
|
| 218 |
+
return
|
| 219 |
+
seen_month_names.add(dedupe_key)
|
| 220 |
+
recurring_by_month[(year, month)].append({
|
| 221 |
+
"name": name[:30],
|
| 222 |
+
"amount": round(abs(amount), 2),
|
| 223 |
+
"date": f"{year:04d}-{month:02d}-{day:02d}",
|
| 224 |
+
"is_income": is_income,
|
| 225 |
+
})
|
| 226 |
+
|
| 227 |
+
for item in recurring_expenses:
|
| 228 |
+
for hist in item.get("history", []):
|
| 229 |
+
hist_date = date.fromisoformat(hist["date"])
|
| 230 |
+
add_recurring_occurrence(
|
| 231 |
+
hist_date.year, hist_date.month, hist_date.day,
|
| 232 |
+
item["merchant"], hist["amount"], is_income=False,
|
| 233 |
+
)
|
| 234 |
+
for item in recurring_income:
|
| 235 |
+
for hist in item.get("history", []):
|
| 236 |
+
hist_date = date.fromisoformat(hist["date"])
|
| 237 |
+
add_recurring_occurrence(
|
| 238 |
+
hist_date.year, hist_date.month, hist_date.day,
|
| 239 |
+
item["merchant"], hist["amount"], is_income=True,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
for ev in projected_events:
|
| 243 |
+
ev_date = date.fromisoformat(ev["date"])
|
| 244 |
+
if ev_date <= today:
|
| 245 |
+
continue
|
| 246 |
+
add_recurring_occurrence(
|
| 247 |
+
ev_date.year, ev_date.month, ev_date.day,
|
| 248 |
+
ev.get("merchant", "Unknown"), ev.get("amount", 0.0),
|
| 249 |
+
is_income=ev.get("is_income", False),
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# Build per-month output
|
| 253 |
+
months_out = []
|
| 254 |
+
for (year, month) in window:
|
| 255 |
+
month_start = date(year, month, 1)
|
| 256 |
+
_, last_day = monthrange(year, month)
|
| 257 |
+
month_end = date(year, month, last_day)
|
| 258 |
+
|
| 259 |
+
is_past = month_end < today
|
| 260 |
+
is_current = month_start <= today <= month_end
|
| 261 |
+
is_future = month_start > today
|
| 262 |
+
|
| 263 |
+
total_spent = 0.0
|
| 264 |
+
total_income = 0.0
|
| 265 |
+
daily_spending = defaultdict(float)
|
| 266 |
+
daily_transactions = defaultdict(list)
|
| 267 |
+
|
| 268 |
+
if not is_future:
|
| 269 |
+
for txn in all_transactions:
|
| 270 |
+
txn_date_val = txn.get('date')
|
| 271 |
+
if isinstance(txn_date_val, str):
|
| 272 |
+
try:
|
| 273 |
+
txn_date = date.fromisoformat(txn_date_val)
|
| 274 |
+
except ValueError:
|
| 275 |
+
continue
|
| 276 |
+
else:
|
| 277 |
+
txn_date = txn_date_val
|
| 278 |
+
|
| 279 |
+
if txn_date < month_start or txn_date > month_end:
|
| 280 |
+
continue
|
| 281 |
+
|
| 282 |
+
amount = txn['amount']
|
| 283 |
+
name = txn.get('name', 'Unknown')
|
| 284 |
+
category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
|
| 285 |
+
name_lower = name.lower()
|
| 286 |
+
|
| 287 |
+
if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
|
| 288 |
+
continue
|
| 289 |
+
|
| 290 |
+
is_income = amount < 0 or (
|
| 291 |
+
category == 'LOAN_PAYMENTS' and
|
| 292 |
+
any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages'])
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
daily_transactions[str(txn_date)].append({
|
| 296 |
+
"name": name[:30],
|
| 297 |
+
"amount": round(abs(amount), 2),
|
| 298 |
+
"is_income": is_income,
|
| 299 |
+
"category": category,
|
| 300 |
+
})
|
| 301 |
+
|
| 302 |
+
if is_income:
|
| 303 |
+
total_income += abs(amount)
|
| 304 |
+
else:
|
| 305 |
+
total_spent += amount
|
| 306 |
+
daily_spending[str(txn_date)] += amount
|
| 307 |
+
|
| 308 |
+
for rec in recurring_by_month.get((year, month), []):
|
| 309 |
+
rec_date_str = rec["date"]
|
| 310 |
+
already_in_day = any(
|
| 311 |
+
t["name"].upper().strip() == rec["name"].upper().strip()
|
| 312 |
+
for t in daily_transactions.get(rec_date_str, [])
|
| 313 |
+
)
|
| 314 |
+
if not already_in_day:
|
| 315 |
+
daily_transactions[rec_date_str].append({
|
| 316 |
+
"name": rec["name"],
|
| 317 |
+
"amount": rec["amount"],
|
| 318 |
+
"is_income": rec["is_income"],
|
| 319 |
+
"category": "RECURRING",
|
| 320 |
+
})
|
| 321 |
+
if is_future:
|
| 322 |
+
if rec["is_income"]:
|
| 323 |
+
total_income += rec["amount"]
|
| 324 |
+
else:
|
| 325 |
+
total_spent += rec["amount"]
|
| 326 |
+
daily_spending[rec_date_str] += rec["amount"]
|
| 327 |
+
|
| 328 |
+
month_recurring = recurring_by_month.get((year, month), [])
|
| 329 |
+
recurring_income_items = [
|
| 330 |
+
{"name": r["name"], "amount": r["amount"], "date": r["date"]}
|
| 331 |
+
for r in month_recurring if r["is_income"]
|
| 332 |
+
]
|
| 333 |
+
recurring_expense_items = [
|
| 334 |
+
{"name": r["name"], "amount": r["amount"], "date": r["date"]}
|
| 335 |
+
for r in month_recurring if not r["is_income"]
|
| 336 |
+
]
|
| 337 |
+
|
| 338 |
+
if is_future:
|
| 339 |
+
total_spent = 0.0
|
| 340 |
+
|
| 341 |
+
days_in_month = last_day
|
| 342 |
+
max_day_spending = max(daily_spending.values()) if daily_spending else 1
|
| 343 |
+
daily_data = []
|
| 344 |
+
for day_num in range(1, days_in_month + 1):
|
| 345 |
+
day_str = str(date(year, month, day_num))
|
| 346 |
+
spent = daily_spending.get(day_str, 0.0)
|
| 347 |
+
intensity = spent / max_day_spending if max_day_spending > 0 else 0
|
| 348 |
+
daily_data.append({
|
| 349 |
+
"day": day_num,
|
| 350 |
+
"amount": round(spent, 2),
|
| 351 |
+
"intensity": round(intensity, 2),
|
| 352 |
+
"transactions": daily_transactions.get(day_str, []),
|
| 353 |
+
})
|
| 354 |
+
|
| 355 |
+
first_weekday = month_start.weekday()
|
| 356 |
+
first_weekday = (first_weekday + 1) % 7
|
| 357 |
+
|
| 358 |
+
months_out.append({
|
| 359 |
+
"year": year,
|
| 360 |
+
"month": month,
|
| 361 |
+
"month_name": month_start.strftime("%b").upper(),
|
| 362 |
+
"month_name_full": month_start.strftime("%B"),
|
| 363 |
+
"total_spent": round(total_spent, 2),
|
| 364 |
+
"total_income": round(total_income, 2),
|
| 365 |
+
"is_past": is_past,
|
| 366 |
+
"is_current": is_current,
|
| 367 |
+
"is_future": is_future,
|
| 368 |
+
"daily_data": daily_data,
|
| 369 |
+
"first_weekday": first_weekday,
|
| 370 |
+
"days_in_month": days_in_month,
|
| 371 |
+
"recurring_income": sorted(recurring_income_items, key=lambda x: -x["amount"])[:5],
|
| 372 |
+
"recurring_expenses": sorted(recurring_expense_items, key=lambda x: -x["amount"])[:10],
|
| 373 |
+
})
|
| 374 |
+
|
| 375 |
+
current_idx = next((i for i, m in enumerate(months_out) if m["is_current"]), 8)
|
| 376 |
+
|
| 377 |
+
return {
|
| 378 |
+
"months": months_out,
|
| 379 |
+
"current_month_index": current_idx,
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
|
| 383 |
def get_cached_financial_context(access_token: str, user_id: str) -> str:
|
| 384 |
now = time.time()
|
|
|
|
| 1489 |
"current_month_index": current_idx,
|
| 1490 |
}
|
| 1491 |
|
| 1492 |
+
|
|
|
|
|
|
|
| 1493 |
@app.post("/csv/chart_data")
|
| 1494 |
async def csv_chart_data(request: Request):
|
| 1495 |
"""Chart data from user-uploaded CSV transactions."""
|
|
|
|
| 1646 |
|
| 1647 |
@app.post("/csv/cashflow/data")
|
| 1648 |
async def csv_cashflow_data(request: Request):
|
| 1649 |
+
"""Cash flow data from user-uploaded CSV transactions."""
|
| 1650 |
body = await request.json()
|
| 1651 |
transactions = body.get("transactions", [])
|
| 1652 |
+
return _build_cashflow_data(transactions)
|
| 1653 |
+
|
| 1654 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1655 |
|
| 1656 |
@app.post("/csv/cashflow/opinion")
|
| 1657 |
async def csv_cashflow_opinion(request: Request):
|
|
|
|
| 1692 |
return StreamingResponse(generate(), media_type="text/event-stream")
|
| 1693 |
|
| 1694 |
|
| 1695 |
+
|
| 1696 |
+
|
| 1697 |
+
# =============================================================================
|
| 1698 |
+
# NOW REPLACE the /csv/yearsphere/data endpoint with this:
|
| 1699 |
+
# =============================================================================
|
| 1700 |
+
|
| 1701 |
@app.post("/csv/yearsphere/data")
|
| 1702 |
async def csv_yearsphere_data(request: Request):
|
| 1703 |
+
"""Year Sphere data from user-uploaded CSV transactions."""
|
| 1704 |
+
from plaid_client import get_recurring_from_csv
|
| 1705 |
+
|
| 1706 |
body = await request.json()
|
| 1707 |
transactions = body.get("transactions", [])
|
| 1708 |
|
| 1709 |
+
if not transactions:
|
| 1710 |
+
return {"months": [], "current_month_index": 0}
|
| 1711 |
|
| 1712 |
+
try:
|
| 1713 |
+
recurring_data = get_recurring_from_csv(transactions)
|
| 1714 |
+
except Exception as e:
|
| 1715 |
+
print(f"CSV yearsphere recurring error: {e}", flush=True)
|
| 1716 |
+
recurring_data = {"recurring_expenses": [], "recurring_income": [], "projected_events": []}
|
| 1717 |
|
| 1718 |
+
return _build_yearsphere_data(transactions, recurring_data)
|
| 1719 |
+
|
| 1720 |
+
|
| 1721 |
+
|
| 1722 |
+
|
| 1723 |
+
@app.post("/csv/categorize")
|
| 1724 |
+
async def csv_categorize(request: Request):
|
| 1725 |
+
"""Categorize PENDING transactions using the AI."""
|
| 1726 |
+
from fiscal import categorize_transactions_batched
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1727 |
|
| 1728 |
+
body = await request.json()
|
| 1729 |
+
transactions = body.get("transactions", [])
|
| 1730 |
+
|
| 1731 |
+
if not transactions:
|
| 1732 |
+
return {"transactions": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1733 |
|
| 1734 |
+
pending_indices = []
|
| 1735 |
+
pending_descriptions = []
|
| 1736 |
+
|
| 1737 |
+
for i, txn in enumerate(transactions):
|
| 1738 |
+
current_cat = txn.get('personal_finance_category', {}).get('primary', '')
|
| 1739 |
+
if current_cat == 'PENDING_CATEGORIZATION':
|
| 1740 |
+
pending_indices.append(i)
|
| 1741 |
+
desc = txn.get('raw_description') or txn.get('name', 'Unknown')
|
| 1742 |
+
pending_descriptions.append(desc)
|
| 1743 |
+
|
| 1744 |
+
if not pending_descriptions:
|
| 1745 |
+
return {
|
| 1746 |
+
"transactions": transactions,
|
| 1747 |
+
"categorized_count": 0,
|
| 1748 |
+
"total_count": len(transactions),
|
| 1749 |
+
}
|
| 1750 |
+
|
| 1751 |
+
print(f"Categorizing {len(pending_descriptions)} pending transactions...", flush=True)
|
| 1752 |
+
|
| 1753 |
+
ai_categories = categorize_transactions_batched(pending_descriptions)
|
| 1754 |
+
|
| 1755 |
+
for idx, category in zip(pending_indices, ai_categories):
|
| 1756 |
+
if 'personal_finance_category' not in transactions[idx]:
|
| 1757 |
+
transactions[idx]['personal_finance_category'] = {}
|
| 1758 |
+
transactions[idx]['personal_finance_category']['primary'] = category
|
| 1759 |
+
|
| 1760 |
+
return {
|
| 1761 |
+
"transactions": transactions,
|
| 1762 |
+
"categorized_count": len(pending_descriptions),
|
| 1763 |
+
"total_count": len(transactions),
|
| 1764 |
+
}
|
fiscal.py
CHANGED
|
@@ -383,7 +383,7 @@ def categorize_transactions_with_ai(descriptions: list) -> list:
|
|
| 383 |
print(f"Categorization error: {e}", flush=True)
|
| 384 |
return ['OTHER'] * len(descriptions)
|
| 385 |
|
| 386 |
-
|
| 387 |
def categorize_transactions_batched(descriptions: list, batch_size: int = 40) -> list:
|
| 388 |
"""
|
| 389 |
Categorize a large list of descriptions by batching.
|
|
|
|
| 383 |
print(f"Categorization error: {e}", flush=True)
|
| 384 |
return ['OTHER'] * len(descriptions)
|
| 385 |
|
| 386 |
+
|
| 387 |
def categorize_transactions_batched(descriptions: list, batch_size: int = 40) -> list:
|
| 388 |
"""
|
| 389 |
Categorize a large list of descriptions by batching.
|
plaid_client.py
CHANGED
|
@@ -62,6 +62,7 @@ def _load_raw_balances():
|
|
| 62 |
raise FileNotFoundError(f"Fixture not found: {path}")
|
| 63 |
return json.loads(path.read_text())
|
| 64 |
|
|
|
|
| 65 |
|
| 66 |
def _shift_fixture_to_today(fixture):
|
| 67 |
"""
|
|
@@ -99,8 +100,6 @@ def _load_raw_transactions():
|
|
| 99 |
fixture = json.loads(path.read_text())
|
| 100 |
return _shift_fixture_to_today(fixture)
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
# ---------- REAL: Balances ----------
|
| 105 |
|
| 106 |
def get_balances(access_token: str) -> str:
|
|
@@ -621,7 +620,6 @@ def get_recurring_from_fixtures() -> dict:
|
|
| 621 |
}
|
| 622 |
|
| 623 |
|
| 624 |
-
|
| 625 |
def _build_synthetic_balances_from_transactions(transactions: list) -> dict:
|
| 626 |
"""
|
| 627 |
CSV mode: infer account balances from transaction history.
|
|
@@ -716,6 +714,4 @@ def get_recurring_from_csv(transactions: list) -> dict:
|
|
| 716 |
'recurring_income': [],
|
| 717 |
'projected_events': [],
|
| 718 |
'analysis_period': None,
|
| 719 |
-
}
|
| 720 |
-
|
| 721 |
-
|
|
|
|
| 62 |
raise FileNotFoundError(f"Fixture not found: {path}")
|
| 63 |
return json.loads(path.read_text())
|
| 64 |
|
| 65 |
+
from datetime import date, timedelta
|
| 66 |
|
| 67 |
def _shift_fixture_to_today(fixture):
|
| 68 |
"""
|
|
|
|
| 100 |
fixture = json.loads(path.read_text())
|
| 101 |
return _shift_fixture_to_today(fixture)
|
| 102 |
|
|
|
|
|
|
|
| 103 |
# ---------- REAL: Balances ----------
|
| 104 |
|
| 105 |
def get_balances(access_token: str) -> str:
|
|
|
|
| 620 |
}
|
| 621 |
|
| 622 |
|
|
|
|
| 623 |
def _build_synthetic_balances_from_transactions(transactions: list) -> dict:
|
| 624 |
"""
|
| 625 |
CSV mode: infer account balances from transaction history.
|
|
|
|
| 714 |
'recurring_income': [],
|
| 715 |
'projected_events': [],
|
| 716 |
'analysis_period': None,
|
| 717 |
+
}
|
|
|
|
|
|