csv Data
Browse files- app.py +318 -1
- plaid_client.py +101 -1
app.py
CHANGED
|
@@ -1183,4 +1183,321 @@ async def trial_yearsphere_data(request: Request):
|
|
| 1183 |
return {
|
| 1184 |
"months": months_out,
|
| 1185 |
"current_month_index": current_idx,
|
| 1186 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
return {
|
| 1184 |
"months": months_out,
|
| 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."""
|
| 1194 |
+
from plaid_client import get_chart_data_from_csv
|
| 1195 |
+
body = await request.json()
|
| 1196 |
+
transactions = body.get("transactions", [])
|
| 1197 |
+
return get_chart_data_from_csv(transactions)
|
| 1198 |
+
|
| 1199 |
+
|
| 1200 |
+
@app.post("/csv/recurring")
|
| 1201 |
+
async def csv_recurring(request: Request):
|
| 1202 |
+
"""Recurring pattern detection from user-uploaded CSV transactions."""
|
| 1203 |
+
from plaid_client import get_recurring_from_csv
|
| 1204 |
+
body = await request.json()
|
| 1205 |
+
transactions = body.get("transactions", [])
|
| 1206 |
+
return get_recurring_from_csv(transactions)
|
| 1207 |
+
|
| 1208 |
+
|
| 1209 |
+
@app.post("/csv/chat/stream")
|
| 1210 |
+
async def csv_chat_stream(request: Request):
|
| 1211 |
+
"""Chat with AI using CSV-uploaded transactions as context."""
|
| 1212 |
+
from plaid_client import get_snapshot_from_csv
|
| 1213 |
+
|
| 1214 |
+
body = await request.json()
|
| 1215 |
+
message = body.get("message", "")
|
| 1216 |
+
transactions = body.get("transactions", [])
|
| 1217 |
+
csv_user_id = f"csv-{request.client.host}"
|
| 1218 |
+
|
| 1219 |
+
financial_context = get_snapshot_from_csv(transactions)
|
| 1220 |
+
|
| 1221 |
+
def generate():
|
| 1222 |
+
for token in stream_answer(message, csv_user_id, financial_context=financial_context):
|
| 1223 |
+
yield f"data: {json.dumps({'chunk': token})}\n\n"
|
| 1224 |
+
yield "data: [DONE]\n\n"
|
| 1225 |
+
|
| 1226 |
+
return StreamingResponse(generate(), media_type="text/event-stream")
|
| 1227 |
+
|
| 1228 |
+
|
| 1229 |
+
@app.post("/csv/reset")
|
| 1230 |
+
async def csv_reset(request: Request):
|
| 1231 |
+
"""Reset chat history for CSV mode."""
|
| 1232 |
+
csv_user_id = f"csv-{request.client.host}"
|
| 1233 |
+
reset_history(csv_user_id)
|
| 1234 |
+
return {"status": "reset"}
|
| 1235 |
+
|
| 1236 |
+
|
| 1237 |
+
@app.post("/csv/prescription/diagnose")
|
| 1238 |
+
async def csv_prescription_diagnose(request: Request):
|
| 1239 |
+
"""Streaming diagnosis using CSV transaction context."""
|
| 1240 |
+
from plaid_client import get_snapshot_from_csv
|
| 1241 |
+
|
| 1242 |
+
body = await request.json()
|
| 1243 |
+
prescription_type = body.get("type", "account")
|
| 1244 |
+
prescription_name = body.get("name", "")
|
| 1245 |
+
prescription_amount = body.get("amount", 0)
|
| 1246 |
+
prescription_details = body.get("details", "")
|
| 1247 |
+
follow_up_question = body.get("question", "")
|
| 1248 |
+
transactions = body.get("transactions", [])
|
| 1249 |
+
|
| 1250 |
+
csv_user_id = f"csv-{request.client.host}"
|
| 1251 |
+
financial_context = get_snapshot_from_csv(transactions)
|
| 1252 |
+
|
| 1253 |
+
if follow_up_question:
|
| 1254 |
+
prompt = f"""The user previously asked you to diagnose their {prescription_type}: "{prescription_name}" ({prescription_details}, currently ${prescription_amount:.2f}).
|
| 1255 |
+
|
| 1256 |
+
They're now asking a follow-up question: "{follow_up_question}"
|
| 1257 |
+
|
| 1258 |
+
Answer their question directly, keeping the same calm, doctor-patient tone. Reference the specific prescription details where relevant."""
|
| 1259 |
+
else:
|
| 1260 |
+
prompt = f"""The user just handed you their {prescription_type} for a check-up: "{prescription_name}" ({prescription_details}, currently ${prescription_amount:.2f}).
|
| 1261 |
+
|
| 1262 |
+
Give a warm, doctor-patient style diagnosis that covers:
|
| 1263 |
+
|
| 1264 |
+
1. **What it is** — a plain-language description of this {prescription_type}
|
| 1265 |
+
2. **The amount** — acknowledge the current amount without judgment
|
| 1266 |
+
3. **How it looks** — is this amount healthy, concerning, or somewhere in between? Be honest but calm.
|
| 1267 |
+
4. **Recent history** — mention what you can observe from their recent activity
|
| 1268 |
+
5. **What could go better** — 1-2 specific, actionable suggestions (only if genuinely useful)
|
| 1269 |
+
6. **Reassurance** — even if there's room to improve, name what's going right. If the account is in good shape, celebrate it warmly.
|
| 1270 |
+
|
| 1271 |
+
Keep the tone calm, non-judgmental, and specific to this {prescription_type}. Don't lecture. Don't use words like "leak" or "spike." Speak like a doctor who cares about the patient, not a financial advisor trying to upsell them.
|
| 1272 |
+
|
| 1273 |
+
Under 200 words. Use markdown headers (**bold**) for each section. Keep sections short — 1-2 sentences each.
|
| 1274 |
+
|
| 1275 |
+
CRITICAL: Respond with prose only. Do NOT include [CHART_DATA]...[/CHART_DATA] blocks, JSON, code fences, or any structured data."""
|
| 1276 |
+
|
| 1277 |
+
def generate():
|
| 1278 |
+
for token in stream_answer(prompt, csv_user_id, financial_context=financial_context):
|
| 1279 |
+
yield f"data: {json.dumps({'chunk': token})}\n\n"
|
| 1280 |
+
yield "data: [DONE]\n\n"
|
| 1281 |
+
|
| 1282 |
+
return StreamingResponse(generate(), media_type="text/event-stream")
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
@app.post("/csv/prescription/activity")
|
| 1286 |
+
async def csv_prescription_activity(request: Request):
|
| 1287 |
+
"""Activity data for the pulse chart from CSV transactions."""
|
| 1288 |
+
body = await request.json()
|
| 1289 |
+
transactions = body.get("transactions", [])
|
| 1290 |
+
|
| 1291 |
+
# Reuse the same activity aggregation logic as trial mode
|
| 1292 |
+
# Group transactions by day, count events
|
| 1293 |
+
from collections import defaultdict
|
| 1294 |
+
from datetime import date, timedelta
|
| 1295 |
+
|
| 1296 |
+
end_date = date.today()
|
| 1297 |
+
start_date = end_date - timedelta(days=90)
|
| 1298 |
+
|
| 1299 |
+
daily_activity = defaultdict(lambda: {'count': 0, 'total_expense': 0, 'total_income': 0})
|
| 1300 |
+
|
| 1301 |
+
for txn in transactions:
|
| 1302 |
+
txn_date_str = txn.get('date', '')
|
| 1303 |
+
try:
|
| 1304 |
+
txn_date = date.fromisoformat(txn_date_str)
|
| 1305 |
+
except (ValueError, TypeError):
|
| 1306 |
+
continue
|
| 1307 |
+
|
| 1308 |
+
if txn_date < start_date or txn_date > end_date:
|
| 1309 |
+
continue
|
| 1310 |
+
|
| 1311 |
+
amount = txn.get('amount', 0)
|
| 1312 |
+
category = txn.get('personal_finance_category', {}).get('primary', '')
|
| 1313 |
+
|
| 1314 |
+
# Skip transfers to avoid double-counting
|
| 1315 |
+
if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
|
| 1316 |
+
continue
|
| 1317 |
+
|
| 1318 |
+
key = txn_date.isoformat()
|
| 1319 |
+
daily_activity[key]['count'] += 1
|
| 1320 |
+
if amount > 0:
|
| 1321 |
+
daily_activity[key]['total_expense'] += amount
|
| 1322 |
+
else:
|
| 1323 |
+
daily_activity[key]['total_income'] += abs(amount)
|
| 1324 |
+
|
| 1325 |
+
# Build activity array
|
| 1326 |
+
activity = []
|
| 1327 |
+
current = start_date
|
| 1328 |
+
while current <= end_date:
|
| 1329 |
+
key = current.isoformat()
|
| 1330 |
+
data = daily_activity.get(key, {'count': 0, 'total_expense': 0, 'total_income': 0})
|
| 1331 |
+
activity.append({
|
| 1332 |
+
'date': key,
|
| 1333 |
+
'value': round(data['total_expense'] + data['total_income'], 2),
|
| 1334 |
+
'count': data['count'],
|
| 1335 |
+
'is_income': data['total_income'] > data['total_expense'],
|
| 1336 |
+
})
|
| 1337 |
+
current += timedelta(days=1)
|
| 1338 |
+
|
| 1339 |
+
return {
|
| 1340 |
+
'activity': activity,
|
| 1341 |
+
'total_events': sum(a['count'] for a in activity),
|
| 1342 |
+
}
|
| 1343 |
+
|
| 1344 |
+
|
| 1345 |
+
@app.post("/csv/cashflow/data")
|
| 1346 |
+
async def csv_cashflow_data(request: Request):
|
| 1347 |
+
"""Cash flow bubble data from CSV transactions."""
|
| 1348 |
+
body = await request.json()
|
| 1349 |
+
transactions = body.get("transactions", [])
|
| 1350 |
+
|
| 1351 |
+
from collections import defaultdict
|
| 1352 |
+
from datetime import date, timedelta
|
| 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):
|
| 1414 |
+
"""AI opinion on cash flow, using CSV transaction context."""
|
| 1415 |
+
from plaid_client import get_snapshot_from_csv
|
| 1416 |
+
|
| 1417 |
+
body = await request.json()
|
| 1418 |
+
transactions = body.get("transactions", [])
|
| 1419 |
+
category_focus = body.get("category", None)
|
| 1420 |
+
|
| 1421 |
+
csv_user_id = f"csv-{request.client.host}"
|
| 1422 |
+
financial_context = get_snapshot_from_csv(transactions)
|
| 1423 |
+
|
| 1424 |
+
if category_focus:
|
| 1425 |
+
prompt = f"""The user is looking at their spending in the "{category_focus}" category. Give them a brief, warm perspective on it.
|
| 1426 |
+
|
| 1427 |
+
Focus on:
|
| 1428 |
+
- What patterns you see in this category
|
| 1429 |
+
- Whether the amount feels reasonable given their overall picture
|
| 1430 |
+
- One specific observation (not a lecture)
|
| 1431 |
+
|
| 1432 |
+
Keep it under 150 words. Calm, non-judgmental tone. No lists — just conversational prose."""
|
| 1433 |
+
else:
|
| 1434 |
+
prompt = """Give the user a brief overview of their cash flow this month.
|
| 1435 |
+
|
| 1436 |
+
Cover:
|
| 1437 |
+
- Overall picture (income vs. expenses)
|
| 1438 |
+
- The category that stands out most
|
| 1439 |
+
- One thing worth noticing (not a lecture, not advice unless it's genuinely useful)
|
| 1440 |
+
|
| 1441 |
+
Keep it under 200 words. Warm, doctor-patient tone. Prose only, no lists."""
|
| 1442 |
+
|
| 1443 |
+
def generate():
|
| 1444 |
+
for token in stream_answer(prompt, csv_user_id, financial_context=financial_context):
|
| 1445 |
+
yield f"data: {json.dumps({'chunk': token})}\n\n"
|
| 1446 |
+
yield "data: [DONE]\n\n"
|
| 1447 |
+
|
| 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 sphere data from CSV transactions."""
|
| 1454 |
+
body = await request.json()
|
| 1455 |
+
transactions = body.get("transactions", [])
|
| 1456 |
+
|
| 1457 |
+
from collections import defaultdict
|
| 1458 |
+
from datetime import date
|
| 1459 |
+
|
| 1460 |
+
# Group by month
|
| 1461 |
+
monthly = defaultdict(lambda: {'income': 0, 'expenses': 0, 'transactions': []})
|
| 1462 |
+
|
| 1463 |
+
for txn in transactions:
|
| 1464 |
+
txn_date_str = txn.get('date', '')
|
| 1465 |
+
try:
|
| 1466 |
+
txn_date = date.fromisoformat(txn_date_str)
|
| 1467 |
+
except (ValueError, TypeError):
|
| 1468 |
+
continue
|
| 1469 |
+
|
| 1470 |
+
month_key = f"{txn_date.year}-{txn_date.month:02d}"
|
| 1471 |
+
amount = txn.get('amount', 0)
|
| 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 |
+
# Build months array
|
| 1491 |
+
months = []
|
| 1492 |
+
for month_key in sorted(monthly.keys()):
|
| 1493 |
+
data = monthly[month_key]
|
| 1494 |
+
months.append({
|
| 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 |
+
return {'months': months}
|
plaid_client.py
CHANGED
|
@@ -618,4 +618,104 @@ def get_recurring_from_fixtures() -> dict:
|
|
| 618 |
'recurring_income': [],
|
| 619 |
'projected_events': [],
|
| 620 |
'analysis_period': None,
|
| 621 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
'recurring_income': [],
|
| 619 |
'projected_events': [],
|
| 620 |
'analysis_period': None,
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def _build_synthetic_balances_from_transactions(transactions: list) -> dict:
|
| 626 |
+
"""
|
| 627 |
+
CSV mode: infer account balances from transaction history.
|
| 628 |
+
We don't have real balances (CSV doesn't include them), so we estimate:
|
| 629 |
+
- For each account_id in the transactions, sum credits - debits to get an inferred balance
|
| 630 |
+
- Use latest transaction as reference point
|
| 631 |
+
|
| 632 |
+
Returns a Plaid-shaped response so downstream formatters work unchanged.
|
| 633 |
+
"""
|
| 634 |
+
from collections import defaultdict
|
| 635 |
+
|
| 636 |
+
account_totals = defaultdict(float)
|
| 637 |
+
account_names = {}
|
| 638 |
+
latest_dates = {}
|
| 639 |
+
|
| 640 |
+
for txn in transactions:
|
| 641 |
+
acc_id = txn.get('account_id', 'csv_account_default')
|
| 642 |
+
amount = txn.get('amount', 0)
|
| 643 |
+
# Plaid convention: positive = expense (money out), negative = income (money in)
|
| 644 |
+
# So current balance = sum of (-amount) = money in minus money out
|
| 645 |
+
account_totals[acc_id] += (-amount)
|
| 646 |
+
|
| 647 |
+
# Track a display name — use account_id or default
|
| 648 |
+
if acc_id not in account_names:
|
| 649 |
+
account_names[acc_id] = f"Uploaded Account {acc_id[-4:]}" if len(acc_id) > 4 else "Uploaded Account"
|
| 650 |
+
|
| 651 |
+
# Track latest date
|
| 652 |
+
txn_date = txn.get('date', '')
|
| 653 |
+
if acc_id not in latest_dates or txn_date > latest_dates[acc_id]:
|
| 654 |
+
latest_dates[acc_id] = txn_date
|
| 655 |
+
|
| 656 |
+
# Build a Plaid-shaped accounts response
|
| 657 |
+
accounts = []
|
| 658 |
+
for acc_id, running_total in account_totals.items():
|
| 659 |
+
accounts.append({
|
| 660 |
+
'account_id': acc_id,
|
| 661 |
+
'name': account_names[acc_id],
|
| 662 |
+
'subtype': 'checking', # assume chequing for CSV uploads
|
| 663 |
+
'balances': {
|
| 664 |
+
'current': round(running_total, 2),
|
| 665 |
+
'available': round(running_total, 2),
|
| 666 |
+
'limit': None,
|
| 667 |
+
}
|
| 668 |
+
})
|
| 669 |
+
|
| 670 |
+
if not accounts:
|
| 671 |
+
# Fallback: single generic account
|
| 672 |
+
accounts.append({
|
| 673 |
+
'account_id': 'csv_account_default',
|
| 674 |
+
'name': 'Uploaded Account',
|
| 675 |
+
'subtype': 'checking',
|
| 676 |
+
'balances': {'current': 0, 'available': 0, 'limit': None}
|
| 677 |
+
})
|
| 678 |
+
|
| 679 |
+
return {'accounts': accounts}
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def get_snapshot_from_csv(transactions: list) -> str:
|
| 683 |
+
"""CSV mode: build financial snapshot from user-uploaded transactions."""
|
| 684 |
+
try:
|
| 685 |
+
balances = _build_synthetic_balances_from_transactions(transactions)
|
| 686 |
+
balance_text = _format_balances(balances)
|
| 687 |
+
transaction_text = _format_transactions(transactions)
|
| 688 |
+
return f"{balance_text}\n\n{transaction_text}"
|
| 689 |
+
except Exception as e:
|
| 690 |
+
print(f"CSV snapshot error: {e}", flush=True)
|
| 691 |
+
return "NO_BANK_DATA"
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def get_chart_data_from_csv(transactions: list) -> dict:
|
| 695 |
+
"""CSV mode: build chart data from user-uploaded transactions."""
|
| 696 |
+
end_date = date.today()
|
| 697 |
+
start_date = end_date.replace(day=1)
|
| 698 |
+
try:
|
| 699 |
+
balances = _build_synthetic_balances_from_transactions(transactions)
|
| 700 |
+
return _build_chart_data(balances, transactions, start_date, end_date)
|
| 701 |
+
except Exception as e:
|
| 702 |
+
print(f"CSV chart_data error: {e}", flush=True)
|
| 703 |
+
return {"burndown": [], "income_total": 0, "expense_total": 0, "accounts": []}
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
def get_recurring_from_csv(transactions: list) -> dict:
|
| 707 |
+
"""CSV mode: detect recurring patterns from user-uploaded transactions."""
|
| 708 |
+
end_date = date.today()
|
| 709 |
+
start_date = end_date - timedelta(days=180)
|
| 710 |
+
try:
|
| 711 |
+
return _detect_recurring(transactions, start_date, end_date)
|
| 712 |
+
except Exception as e:
|
| 713 |
+
print(f"CSV recurring error: {e}", flush=True)
|
| 714 |
+
return {
|
| 715 |
+
'recurring_expenses': [],
|
| 716 |
+
'recurring_income': [],
|
| 717 |
+
'projected_events': [],
|
| 718 |
+
'analysis_period': None,
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
|