MJ-Prod commited on
Commit
e7b9446
·
1 Parent(s): fb03164

csv Data_4

Browse files
Files changed (3) hide show
  1. app.py +370 -109
  2. fiscal.py +1 -1
  3. 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 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):
@@ -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 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}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }