MJ-Prod commited on
Commit
3a7d261
·
1 Parent(s): ad86004

add chroma_db to Docker, skip rebuild on startup

Browse files
Files changed (2) hide show
  1. Dockerfile +2 -1
  2. plaid_client.py +28 -965
Dockerfile CHANGED
@@ -7,8 +7,9 @@ RUN pip install --no-cache-dir -r requirements.txt
7
 
8
  COPY app.py fiscal.py auth.py plaid_client.py rebuild_index.py ./
9
  COPY docs/ ./docs/
 
10
 
11
  EXPOSE 7860
12
 
13
- # Rebuild ChromaDB then start the server
14
  CMD ["sh", "-c", "python rebuild_index.py && uvicorn app:app --host 0.0.0.0 --port 7860"]
 
7
 
8
  COPY app.py fiscal.py auth.py plaid_client.py rebuild_index.py ./
9
  COPY docs/ ./docs/
10
+ COPY chroma_db/ ./chroma_db/
11
 
12
  EXPOSE 7860
13
 
14
+ # Skip rebuild if chroma_db exists, then start
15
  CMD ["sh", "-c", "python rebuild_index.py && uvicorn app:app --host 0.0.0.0 --port 7860"]
plaid_client.py CHANGED
@@ -1,973 +1,36 @@
1
  import os
2
- from datetime import date, timedelta
3
- from plaid.api import plaid_api
4
- from plaid.configuration import Configuration
5
- from plaid.api_client import ApiClient
6
- from plaid.model.products import Products
7
- from plaid.model.country_code import CountryCode
8
- from plaid.exceptions import ApiException
9
- from plaid.model.accounts_balance_get_request import AccountsBalanceGetRequest
10
- from plaid.model.transactions_get_request import TransactionsGetRequest
11
- from plaid.model.transactions_get_request_options import TransactionsGetRequestOptions
12
- from plaid.model.link_token_create_request import LinkTokenCreateRequest
13
- from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser
14
- from plaid.model.item_public_token_exchange_request import ItemPublicTokenExchangeRequest
15
- from collections import defaultdict
16
- from datetime import date, timedelta
17
- import re
18
 
19
- # ---------- Client setup ----------
20
-
21
- def _make_client():
22
- env = os.environ.get("PLAID_ENV", "sandbox")
23
- host = {
24
- "sandbox": "https://sandbox.plaid.com",
25
- "development": "https://development.plaid.com",
26
- "production": "https://production.plaid.com",
27
- }[env]
28
-
29
- config = Configuration(
30
- host=host,
31
- api_key={
32
- "clientId": os.environ["PLAID_CLIENT_ID"], # reads from .env
33
- "secret": os.environ["PLAID_SECRET"]
34
- }
 
35
  )
36
- return plaid_api.PlaidApi(ApiClient(config))
37
-
38
- plaid_client = _make_client()
39
-
40
-
41
- # ---------- Balances ----------
42
-
43
- def get_balances(access_token: str) -> str:
44
- request = AccountsBalanceGetRequest(access_token=access_token)
45
- response = plaid_client.accounts_balance_get(request)
46
-
47
- lines = ["USER'S CURRENT ACCOUNT BALANCES:"]
48
- for account in response['accounts']:
49
- name = account['name']
50
- subtype = str(account['subtype'])
51
- current = account['balances']['current']
52
-
53
- if current is None:
54
- continue
55
-
56
- # Skip mortgage and line of credit — not relevant for daily budgeting
57
- if subtype in ['mortgage', 'line of credit']:
58
- continue
59
-
60
- if subtype == 'credit card':
61
- limit = account['balances']['limit'] or 0
62
- owing = current
63
- available = limit - current if limit > 0 else 0
64
- lines.append(
65
- f"- {name} (Credit Card): "
66
- f"${owing:.2f} owing, ${available:.2f} available out of ${limit:.2f} limit"
67
- )
68
- elif subtype == 'checking':
69
- available = account['balances']['available'] or current
70
- lines.append(f"- {name} (Chequing): ${current:.2f} balance, ${available:.2f} available")
71
- elif subtype == 'savings':
72
- lines.append(f"- {name} (Savings): ${current:.2f}")
73
- elif subtype == 'rrsp':
74
- lines.append(f"- {name} (RRSP): ${current:.2f}")
75
- else:
76
- lines.append(f"- {name} ({subtype}): ${current:.2f}")
77
 
78
- return "\n".join(lines)
79
- request = AccountsBalanceGetRequest(access_token=access_token)
80
- response = plaid_client.accounts_balance_get(request)
81
-
82
- lines = ["USER'S CURRENT ACCOUNT BALANCES:"]
83
- for account in response['accounts']:
84
- name = account['name']
85
- subtype = account['subtype']
86
- current = account['balances']['current']
87
-
88
- if current is None:
89
- continue
90
-
91
- if subtype == 'credit card':
92
- limit = account['balances']['limit'] or 0
93
- available = account['balances']['available'] or (limit - current)
94
- lines.append(
95
- f"- {name} (Credit Card): "
96
- f"${current:.2f} owing, ${available:.2f} available"
97
- )
98
- elif subtype in ['mortgage', 'line of credit']:
99
- lines.append(f"- {name} ({subtype}): ${current:.2f} outstanding")
100
- else:
101
- lines.append(f"- {name} ({subtype}): ${current:.2f}")
102
-
103
- return "\n".join(lines)
104
-
105
- # ---------- Transactions ----------
106
-
107
- def get_transactions(access_token: str, days: int = 30) -> str:
108
- end_date = date.today()
109
- start_date = end_date - timedelta(days=days)
110
- week_start = end_date - timedelta(days=end_date.weekday())
111
-
112
- request = TransactionsGetRequest(
113
- access_token=access_token,
114
- start_date=start_date,
115
- end_date=end_date,
116
- options=TransactionsGetRequestOptions(
117
- count=200,
118
- include_personal_finance_category=True
119
- )
120
  )
121
- response = plaid_client.transactions_get(request)
122
- transactions = response['transactions']
123
-
124
- if not transactions:
125
- return "No transactions found in the last 30 days."
126
-
127
- today_total = 0.0
128
- week_total = 0.0
129
- month_total = 0.0
130
- income_total = 0.0
131
- category_totals: dict[str, float] = {}
132
-
133
- for txn in transactions:
134
- amount = txn['amount']
135
- name = txn['name']
136
- txn_date = txn['date']
137
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
138
-
139
- # Skip internal transfers between own accounts
140
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
141
- continue
142
-
143
- # Detect payroll mislabeled as loan payments
144
- name_lower = name.lower()
145
- if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
146
- income_total += amount
147
- continue
148
-
149
- # Skip other loan payments (mortgage, LOC) — not daily spending
150
- if category == 'LOAN_PAYMENTS':
151
- continue
152
-
153
- # Income (negative = money in)
154
- if amount < 0:
155
- income_total += abs(amount)
156
- continue
157
-
158
- # Spending
159
- month_total += amount
160
- readable = category.replace("_", " ").title()
161
- category_totals[readable] = category_totals.get(readable, 0) + amount
162
-
163
- if txn_date >= week_start:
164
- week_total += amount
165
-
166
- if txn_date == end_date:
167
- today_total += amount
168
-
169
- lines = ["FINANCIAL SUMMARY (pre-calculated, do NOT recalculate):"]
170
- lines.append(f"Today's spending: ${today_total:.2f}")
171
- lines.append(f"This week's spending (since {week_start}): ${week_total:.2f}")
172
- lines.append(f"This month's spending (last {days} days): ${month_total:.2f}")
173
-
174
- if income_total > 0:
175
- lines.append(f"Income received (last {days} days): +${income_total:.2f}")
176
-
177
- lines.append(f"Average daily spending: ${month_total / max(days, 1):.2f}")
178
-
179
- lines.append("\nSPENDING BY CATEGORY:")
180
- for category, total in sorted(category_totals.items(), key=lambda x: -x[1]):
181
- pct = (total / month_total * 100) if month_total > 0 else 0
182
- lines.append(f"- {category}: ${total:.2f} ({pct:.0f}%)")
183
-
184
- lines.append("\nRECENT TRANSACTIONS (last 5):")
185
- count = 0
186
- for txn in transactions:
187
- if count >= 5:
188
- break
189
- amount = txn['amount']
190
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
191
- if amount <= 0 or category in ['TRANSFER_IN', 'TRANSFER_OUT', 'LOAN_PAYMENTS']:
192
- continue
193
- lines.append(f" - {txn['date']} {txn['name']}: ${amount:.2f}")
194
- count += 1
195
-
196
- return "\n".join(lines)
197
- end_date = date.today()
198
- start_date = end_date - timedelta(days=days)
199
- week_start = end_date - timedelta(days=end_date.weekday())
200
-
201
- request = TransactionsGetRequest(
202
- access_token=access_token,
203
- start_date=start_date,
204
- end_date=end_date,
205
- options=TransactionsGetRequestOptions(
206
- count=100,
207
- include_personal_finance_category=True
208
- )
209
- )
210
- response = plaid_client.transactions_get(request)
211
- transactions = response['transactions']
212
-
213
- if not transactions:
214
- return "No transactions found in the last 30 days."
215
-
216
- # Pre-calculate everything — never let LLM do math
217
- today_total = 0.0
218
- week_total = 0.0
219
- month_total = 0.0
220
- income_total = 0.0
221
- category_totals: dict[str, float] = {}
222
- today_transactions: list[str] = []
223
- week_transactions: list[str] = []
224
-
225
- for txn in transactions:
226
- amount = txn['amount']
227
- name = txn['name']
228
- txn_date = txn['date']
229
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
230
-
231
- # Income (negative = money in)
232
- if amount < 0:
233
- income_total += abs(amount)
234
- continue
235
-
236
- # Skip internal transfers
237
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
238
- continue
239
-
240
- name_lower = name.lower()
241
- if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
242
- income_total += amount
243
- continue
244
-
245
- # Spending totals by time period
246
- month_total += amount
247
- category_totals[category] = category_totals.get(category, 0) + amount
248
-
249
- if txn_date >= week_start:
250
- week_total += amount
251
- week_transactions.append(f" - {txn_date} {name}: ${amount:.2f}")
252
-
253
- if txn_date == end_date:
254
- today_total += amount
255
- today_transactions.append(f" - {name}: ${amount:.2f}")
256
-
257
- # Build compact summary — no raw transaction dumps
258
- lines = ["FINANCIAL SUMMARY (pre-calculated, do NOT recalculate):"]
259
- lines.append(f"Today's spending: ${today_total:.2f}")
260
- lines.append(f"This week's spending (since {week_start}): ${week_total:.2f}")
261
- lines.append(f"This month's spending (last {days} days): ${month_total:.2f}")
262
-
263
- if income_total > 0:
264
- lines.append(f"Income received (last {days} days): +${income_total:.2f}")
265
-
266
- lines.append(f"Average daily spending: ${month_total / max(days, 1):.2f}")
267
-
268
- lines.append("\nSPENDING BY CATEGORY:")
269
- for category, total in sorted(category_totals.items(), key=lambda x: -x[1]):
270
- readable = category.replace("_", " ").title()
271
- pct = (total / month_total * 100) if month_total > 0 else 0
272
- lines.append(f"- {readable}: ${total:.2f} ({pct:.0f}%)")
273
-
274
- # Only include recent transactions, limited to 5
275
- lines.append("\nRECENT TRANSACTIONS (last 5):")
276
- for txn in transactions[:5]:
277
- amount = txn['amount']
278
- if amount <= 0:
279
- continue
280
- prefix = "-"
281
- lines.append(f" {prefix} {txn['date']} {txn['name']}: ${abs(amount):.2f}")
282
-
283
- return "\n".join(lines)
284
-
285
- # ---------- Combined snapshot ----------
286
-
287
- def get_financial_snapshot(access_token: str) -> str:
288
- try:
289
- balances = get_balances(access_token)
290
- transactions = get_transactions(access_token)
291
- return f"{balances}\n\n{transactions}"
292
- except Exception as e:
293
- error_str = str(e)
294
- print(f"Plaid API Exception caught: {error_str}", flush=True)
295
- if "ITEM_LOGIN_REQUIRED" in error_str:
296
- return "BANK_REAUTH_REQUIRED"
297
- return "NO_BANK_DATA"
298
-
299
- # ---------- Update Mode Link Token ----------
300
-
301
- def create_update_link_token(user_id: str, access_token: str) -> str:
302
- """
303
- Creates a Plaid Link token in Update Mode for a broken access token.
304
- Launches direct bank sync to clear the ITEM_LOGIN_REQUIRED status.
305
- """
306
- request = LinkTokenCreateRequest(
307
- user=LinkTokenCreateRequestUser(client_user_id=user_id),
308
- client_name="FISCAL",
309
- country_codes=[CountryCode("CA")],
310
- language="en",
311
- access_token=access_token # Passing this parameter triggers Update Mode
312
- )
313
- response = plaid_client.link_token_create(request)
314
- return response["link_token"]
315
-
316
-
317
- def create_link_token(user_id: str) -> str:
318
- """Creates a Plaid Link token for the given user."""
319
- request = LinkTokenCreateRequest(
320
- user=LinkTokenCreateRequestUser(client_user_id=user_id),
321
- client_name="FISCAL",
322
- products=[Products("transactions")],
323
- country_codes=[CountryCode("CA")],
324
- language="en",
325
- )
326
- response = plaid_client.link_token_create(request)
327
- return response["link_token"]
328
-
329
- def exchange_public_token(public_token: str) -> str:
330
- """Exchanges a public token for a permanent access token."""
331
- request = ItemPublicTokenExchangeRequest(public_token=public_token)
332
- response = plaid_client.item_public_token_exchange(request)
333
- return response["access_token"]
334
-
335
-
336
- def get_chart_data(access_token: str) -> dict:
337
- """Returns burn-down velocity chart data based on chequing account."""
338
- end_date = date.today()
339
- start_date = end_date.replace(day=1)
340
-
341
- # Get balances
342
- accounts = []
343
- chequing_balance = 0.0
344
- chequing_account_id = None
345
-
346
- try:
347
- balance_req = AccountsBalanceGetRequest(access_token=access_token)
348
- balance_resp = plaid_client.accounts_balance_get(balance_req)
349
- for account in balance_resp['accounts']:
350
- current = account['balances']['current']
351
- if current is None:
352
- continue
353
- subtype = str(account['subtype'])
354
-
355
- # Find the main chequing account
356
- if subtype == 'checking':
357
- chequing_balance = current
358
- chequing_account_id = account['account_id']
359
-
360
- # Skip mortgage and line of credit from display
361
- if subtype in ['mortgage', 'line of credit']:
362
- continue
363
-
364
- accounts.append({
365
- "name": account['name'],
366
- "type": subtype,
367
- "balance": round(current, 2),
368
- })
369
- except Exception as e:
370
- print(f"Balance error in chart_data: {e}", flush=True)
371
-
372
- # Get transactions
373
- burndown = []
374
- income_total = 0.0
375
- expense_total = 0.0
376
-
377
- try:
378
- request = TransactionsGetRequest(
379
- access_token=access_token,
380
- start_date=start_date,
381
- end_date=end_date,
382
- options=TransactionsGetRequestOptions(
383
- count=300,
384
- include_personal_finance_category=True
385
- )
386
- )
387
- response = plaid_client.transactions_get(request)
388
- transactions = response['transactions']
389
-
390
- # Separate spending by day across chequing + credit cards
391
- daily_net: dict[str, float] = {}
392
-
393
- for txn in transactions:
394
- amount = txn['amount']
395
- txn_date = str(txn['date'])
396
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
397
-
398
- # Skip internal transfers between own accounts
399
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
400
- continue
401
-
402
- # Skip loan payments that are actually payroll
403
- name_lower = txn['name'].lower()
404
- if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
405
- income_total += amount
406
- daily_net[txn_date] = daily_net.get(txn_date, 0) - amount # income adds to balance
407
- continue
408
-
409
- if amount < 0:
410
- # Money coming in (income)
411
- income_total += abs(amount)
412
- daily_net[txn_date] = daily_net.get(txn_date, 0) + abs(amount) # adds to balance
413
- else:
414
- # Money going out (spending)
415
- expense_total += amount
416
- daily_net[txn_date] = daily_net.get(txn_date, 0) - amount # subtracts from balance
417
-
418
- # Reconstruct daily balance working BACKWARD from today's chequing balance
419
- # today_balance = chequing_balance
420
- # yesterday_balance = today_balance - net_change_today
421
- # (because net_change is already applied to get to today's balance)
422
-
423
- daily_balances: dict[str, float] = {}
424
- running = chequing_balance
425
-
426
- # Work backward from today
427
- current = end_date
428
- while current >= start_date:
429
- day_str = str(current)
430
- daily_balances[day_str] = running
431
- # Undo this day's net change to get previous day's balance
432
- net_today = daily_net.get(day_str, 0)
433
- running = running - net_today # undo: if we spent $50 (net=-50), previous day was +50 higher
434
- current -= timedelta(days=1)
435
-
436
- # Now build burndown in forward order
437
- current = start_date
438
- while current <= end_date:
439
- day_str = str(current)
440
- balance = daily_balances.get(day_str, 0)
441
- day_spend = 0
442
-
443
- # Calculate just spending for this day (not net)
444
- for txn in transactions:
445
- if str(txn['date']) == day_str and txn['amount'] > 0:
446
- cat = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
447
- if cat not in ['TRANSFER_IN', 'TRANSFER_OUT']:
448
- day_spend += txn['amount']
449
 
450
- burndown.append({
451
- "date": current.strftime("%b %d"),
452
- "balance": round(balance, 2),
453
- "spent": round(day_spend, 2),
454
- })
455
- current += timedelta(days=1)
456
-
457
- except Exception as e:
458
- print(f"Transaction error in chart_data: {e}", flush=True)
459
-
460
- return {
461
- "burndown": burndown,
462
- "income_total": round(income_total, 2),
463
- "expense_total": round(expense_total, 2),
464
- "accounts": accounts,
465
- }
466
- """Returns burn-down velocity chart data."""
467
- end_date = date.today()
468
- start_date = end_date.replace(day=1) # Start of current month
469
-
470
- # Get balances
471
- accounts = []
472
- try:
473
- balance_req = AccountsBalanceGetRequest(access_token=access_token)
474
- balance_resp = plaid_client.accounts_balance_get(balance_req)
475
- for account in balance_resp['accounts']:
476
- current = account['balances']['current']
477
- if current is None:
478
- continue
479
- accounts.append({
480
- "name": account['name'],
481
- "type": str(account['subtype']),
482
- "balance": round(current, 2),
483
- })
484
- except Exception as e:
485
- print(f"Balance error in chart_data: {e}", flush=True)
486
-
487
- # Get transactions for current month
488
- burndown = []
489
- income_total = 0.0
490
- expense_total = 0.0
491
-
492
- try:
493
- request = TransactionsGetRequest(
494
- access_token=access_token,
495
- start_date=start_date,
496
- end_date=end_date,
497
- options=TransactionsGetRequestOptions(
498
- count=200,
499
- include_personal_finance_category=True
500
- )
501
- )
502
- response = plaid_client.transactions_get(request)
503
- transactions = response['transactions']
504
-
505
- # Build daily income and spending
506
- daily_income: dict[str, float] = {}
507
- daily_spending: dict[str, float] = {}
508
-
509
- for txn in transactions:
510
- amount = txn['amount']
511
- txn_date = str(txn['date'])
512
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
513
-
514
- if amount < 0:
515
- income_total += abs(amount)
516
- daily_income[txn_date] = daily_income.get(txn_date, 0) + abs(amount)
517
- continue
518
-
519
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
520
- continue
521
-
522
- name_lower = txn['name'].lower()
523
- if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
524
- income_total += amount
525
- daily_income[txn_date] = daily_income.get(txn_date, 0) + amount
526
- continue
527
-
528
- expense_total += amount
529
- daily_spending[txn_date] = daily_spending.get(txn_date, 0) + amount
530
-
531
- # Build burn-down: start with total income, subtract spending each day
532
- running_balance = income_total
533
- current = start_date
534
- while current <= end_date:
535
- day_str = str(current)
536
- day_spend = daily_spending.get(day_str, 0)
537
- day_income = daily_income.get(day_str, 0)
538
- running_balance = running_balance - day_spend + (day_income if current != start_date else 0)
539
-
540
- burndown.append({
541
- "date": current.strftime("%b %d"),
542
- "balance": round(running_balance, 2),
543
- "spent": round(day_spend, 2),
544
- })
545
- current += timedelta(days=1)
546
-
547
- except Exception as e:
548
- print(f"Transaction error in chart_data: {e}", flush=True)
549
-
550
- return {
551
- "burndown": burndown,
552
- "income_total": round(income_total, 2),
553
- "expense_total": round(expense_total, 2),
554
- "accounts": accounts,
555
- }
556
- """Returns structured JSON for frontend charts."""
557
- end_date = date.today()
558
- start_date = end_date - timedelta(days=30)
559
-
560
- # Get balances (usually ready immediately)
561
- try:
562
- balance_req = AccountsBalanceGetRequest(access_token=access_token)
563
- balance_resp = plaid_client.accounts_balance_get(balance_req)
564
- accounts = []
565
- for account in balance_resp['accounts']:
566
- current = account['balances']['current']
567
- if current is None:
568
- continue
569
- accounts.append({
570
- "name": account['name'],
571
- "type": str(account['subtype']),
572
- "balance": round(current, 2),
573
- })
574
- except Exception as e:
575
- print(f"Balance error in chart_data: {e}", flush=True)
576
- accounts = []
577
-
578
- # Get transactions (may not be ready yet)
579
- category_chart = []
580
- weekly_data = []
581
- income_total = 0.0
582
- expense_total = 0.0
583
-
584
- try:
585
- request = TransactionsGetRequest(
586
- access_token=access_token,
587
- start_date=start_date,
588
- end_date=end_date,
589
- options=TransactionsGetRequestOptions(
590
- count=200,
591
- include_personal_finance_category=True
592
- )
593
- )
594
- response = plaid_client.transactions_get(request)
595
- transactions = response['transactions']
596
-
597
- category_totals: dict[str, float] = {}
598
- daily_spending: dict[str, float] = {}
599
- daily_income: dict[str, float] = {}
600
-
601
- for txn in transactions:
602
- amount = txn['amount']
603
- txn_date = str(txn['date'])
604
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
605
-
606
- if amount < 0:
607
- income_total += abs(amount)
608
- daily_income[txn_date] = daily_income.get(txn_date, 0) + abs(amount)
609
- continue
610
-
611
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
612
- continue
613
-
614
- name_lower = txn['name'].lower()
615
- if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
616
- income_total += amount
617
- daily_income[txn_date] = daily_income.get(txn_date, 0) + amount
618
- continue
619
-
620
- expense_total += amount
621
- readable = category.replace("_", " ").title()
622
- category_totals[readable] = category_totals.get(readable, 0) + amount
623
- daily_spending[txn_date] = daily_spending.get(txn_date, 0) + amount
624
-
625
- category_chart = [
626
- {"name": cat, "value": round(total, 2)}
627
- for cat, total in sorted(category_totals.items(), key=lambda x: -x[1])
628
- ]
629
-
630
- for i in range(4):
631
- week_end = end_date - timedelta(weeks=i)
632
- week_start_d = week_end - timedelta(days=6)
633
- week_label = f"{week_start_d.strftime('%b %d')} - {week_end.strftime('%b %d')}"
634
- week_spending = sum(v for k, v in daily_spending.items() if str(week_start_d) <= k <= str(week_end))
635
- week_income = sum(v for k, v in daily_income.items() if str(week_start_d) <= k <= str(week_end))
636
- weekly_data.append({"week": week_label, "spending": round(week_spending, 2), "income": round(week_income, 2)})
637
- weekly_data.reverse()
638
-
639
- except Exception as e:
640
- print(f"Transaction error in chart_data: {e}", flush=True)
641
-
642
- return {
643
- "category_chart": category_chart,
644
- "weekly_chart": weekly_data,
645
- "income_vs_expenses": {
646
- "income": round(income_total, 2),
647
- "expenses": round(expense_total, 2),
648
- },
649
- "accounts": accounts,
650
- }
651
- """Returns structured JSON for frontend charts."""
652
- end_date = date.today()
653
- start_date = end_date - timedelta(days=30)
654
-
655
- # Get transactions
656
- request = TransactionsGetRequest(
657
- access_token=access_token,
658
- start_date=start_date,
659
- end_date=end_date,
660
- options=TransactionsGetRequestOptions(
661
- count=200,
662
- include_personal_finance_category=True
663
- )
664
  )
665
- response = plaid_client.transactions_get(request)
666
- transactions = response['transactions']
667
-
668
- # Get balances
669
- balance_req = AccountsBalanceGetRequest(access_token=access_token)
670
- balance_resp = plaid_client.accounts_balance_get(balance_req)
671
-
672
- # 1. Spending by category
673
- category_totals: dict[str, float] = {}
674
- income_total = 0.0
675
- expense_total = 0.0
676
-
677
- # 2. Daily spending for weekly breakdown
678
- daily_spending: dict[str, float] = {}
679
- daily_income: dict[str, float] = {}
680
-
681
- for txn in transactions:
682
- amount = txn['amount']
683
- txn_date = str(txn['date'])
684
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
685
-
686
- if amount < 0:
687
- income_total += abs(amount)
688
- daily_income[txn_date] = daily_income.get(txn_date, 0) + abs(amount)
689
- continue
690
-
691
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
692
- continue
693
-
694
- name_lower = txn['name'].lower()
695
- if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
696
- income_total += amount
697
- daily_income[txn_date] = daily_income.get(txn_date, 0) + amount
698
- continue
699
-
700
- expense_total += amount
701
- readable = category.replace("_", " ").title()
702
- category_totals[readable] = category_totals.get(readable, 0) + amount
703
- daily_spending[txn_date] = daily_spending.get(txn_date, 0) + amount
704
-
705
- # Build category chart data (sorted by amount)
706
- category_chart = [
707
- {"name": cat, "value": round(total, 2)}
708
- for cat, total in sorted(category_totals.items(), key=lambda x: -x[1])
709
- ]
710
-
711
- # Build weekly chart data (last 4 weeks)
712
- weekly_data = []
713
- for i in range(4):
714
- week_end = end_date - timedelta(weeks=i)
715
- week_start = week_end - timedelta(days=6)
716
- week_label = f"{week_start.strftime('%b %d')} - {week_end.strftime('%b %d')}"
717
-
718
- week_spending = sum(
719
- v for k, v in daily_spending.items()
720
- if str(week_start) <= k <= str(week_end)
721
- )
722
- week_income = sum(
723
- v for k, v in daily_income.items()
724
- if str(week_start) <= k <= str(week_end)
725
- )
726
-
727
- weekly_data.append({
728
- "week": week_label,
729
- "spending": round(week_spending, 2),
730
- "income": round(week_income, 2),
731
- })
732
-
733
- weekly_data.reverse()
734
-
735
- # Build account balances
736
- accounts = []
737
- for account in balance_resp['accounts']:
738
- current = account['balances']['current']
739
- if current is None:
740
- continue
741
- accounts.append({
742
- "name": account['name'],
743
- "type": str(account['subtype']),
744
- "balance": round(current, 2),
745
- })
746
-
747
- return {
748
- "category_chart": category_chart,
749
- "weekly_chart": weekly_data,
750
- "income_vs_expenses": {
751
- "income": round(income_total, 2),
752
- "expenses": round(expense_total, 2),
753
- },
754
- "accounts": accounts,
755
- }
756
-
757
-
758
- def get_recurring_transactions(access_token: str) -> dict:
759
- """Analyze 6 months of transactions to find recurring patterns."""
760
- end_date = date.today()
761
- start_date = end_date - timedelta(days=180)
762
-
763
- try:
764
- # Fetch all transactions for 6 months
765
- all_transactions = []
766
- offset = 0
767
- while True:
768
- request = TransactionsGetRequest(
769
- access_token=access_token,
770
- start_date=start_date,
771
- end_date=end_date,
772
- options=TransactionsGetRequestOptions(
773
- count=500,
774
- offset=offset,
775
- include_personal_finance_category=True
776
- )
777
- )
778
- response = plaid_client.transactions_get(request)
779
- all_transactions.extend(response['transactions'])
780
- if len(all_transactions) >= response['total_transactions']:
781
- break
782
- offset = len(all_transactions)
783
-
784
- # ... rest of the function stays the same ...
785
-
786
- except Exception as e:
787
- error_str = str(e)
788
- print(f"Recurring transaction error: {error_str}", flush=True)
789
-
790
- # Check if it's PRODUCT_NOT_READY
791
- not_ready = 'PRODUCT_NOT_READY' in error_str or 'not yet ready' in error_str
792
-
793
- return {
794
- 'recurring_expenses': [],
795
- 'recurring_income': [],
796
- 'projected_events': [],
797
- 'analysis_period': None,
798
- 'not_ready': not_ready,
799
- }
800
- """Analyze 6 months of transactions to find recurring patterns."""
801
- end_date = date.today()
802
- start_date = end_date - timedelta(days=180) # 6 months back
803
-
804
- try:
805
- # Fetch all transactions for 6 months
806
- all_transactions = []
807
- offset = 0
808
- while True:
809
- request = TransactionsGetRequest(
810
- access_token=access_token,
811
- start_date=start_date,
812
- end_date=end_date,
813
- options=TransactionsGetRequestOptions(
814
- count=500,
815
- offset=offset,
816
- include_personal_finance_category=True
817
- )
818
- )
819
- response = plaid_client.transactions_get(request)
820
- all_transactions.extend(response['transactions'])
821
- if len(all_transactions) >= response['total_transactions']:
822
- break
823
- offset = len(all_transactions)
824
-
825
- # Group transactions by normalized merchant name
826
- merchant_groups: dict[str, list] = defaultdict(list)
827
-
828
- for txn in all_transactions:
829
- name = txn['name']
830
- amount = txn['amount']
831
- txn_date = txn['date']
832
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
833
-
834
- # Skip transfers
835
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
836
- continue
837
-
838
- # Normalize merchant name (remove numbers, extra spaces)
839
- normalized = re.sub(r'[0-9#]+', '', name).strip()
840
- normalized = re.sub(r'\s+', ' ', normalized).upper()
841
-
842
- merchant_groups[normalized].append({
843
- 'date': str(txn_date),
844
- 'amount': amount,
845
- 'name': name,
846
- 'category': category,
847
- })
848
-
849
- # Detect recurring patterns
850
- recurring = []
851
- income = []
852
-
853
- for merchant, txns in merchant_groups.items():
854
- if len(txns) < 3: # Need at least 3 occurrences
855
- continue
856
-
857
- # Sort by date
858
- txns.sort(key=lambda x: x['date'])
859
-
860
- # Check if amounts are consistent (within 10% variance)
861
- amounts = [t['amount'] for t in txns]
862
- avg_amount = sum(amounts) / len(amounts)
863
- if avg_amount == 0:
864
- continue
865
- variance = max(amounts) - min(amounts)
866
- is_consistent_amount = variance / abs(avg_amount) < 0.15
867
-
868
- if not is_consistent_amount:
869
- continue
870
-
871
- # Detect frequency by analyzing gaps between transactions
872
- dates = [date.fromisoformat(t['date']) for t in txns]
873
- gaps = [(dates[i+1] - dates[i]).days for i in range(len(dates)-1)]
874
- avg_gap = sum(gaps) / len(gaps) if gaps else 0
875
-
876
- # Classify frequency
877
- if 5 <= avg_gap <= 10:
878
- frequency = 'weekly'
879
- freq_days = 7
880
- elif 12 <= avg_gap <= 17:
881
- frequency = 'biweekly'
882
- freq_days = 14
883
- elif 25 <= avg_gap <= 35:
884
- frequency = 'monthly'
885
- freq_days = 30
886
- else:
887
- continue # Irregular — skip
888
-
889
- # Count consecutive months present
890
- months_present = set()
891
- for t in txns:
892
- d = date.fromisoformat(t['date'])
893
- months_present.add(f"{d.year}-{d.month:02d}")
894
-
895
- consecutive = len(months_present)
896
-
897
- # Determine if it's income or expense
898
- is_income = avg_amount < 0 # Negative = money in
899
-
900
- entry = {
901
- 'merchant': txns[0]['name'],
902
- 'normalized_name': merchant,
903
- 'amount': round(abs(avg_amount), 2),
904
- 'frequency': frequency,
905
- 'frequency_days': freq_days,
906
- 'category': txns[0]['category'],
907
- 'consecutive_months': consecutive,
908
- 'last_date': txns[-1]['date'],
909
- 'is_income': is_income,
910
- 'history': [{'date': t['date'], 'amount': t['amount']} for t in txns],
911
- }
912
-
913
- if consecutive >= 5:
914
- entry['status'] = 'confirmed' # 5+ months = add indefinitely
915
- elif consecutive >= 3:
916
- entry['status'] = 'likely'
917
- else:
918
- entry['status'] = 'possible'
919
-
920
- if is_income:
921
- income.append(entry)
922
- else:
923
- recurring.append(entry)
924
-
925
- # Sort by amount descending
926
- recurring.sort(key=lambda x: -x['amount'])
927
- income.sort(key=lambda x: -x['amount'])
928
-
929
- # Project future dates for confirmed recurring items
930
- projected_events = []
931
- today = date.today()
932
-
933
- for item in recurring + income:
934
- last = date.fromisoformat(item['last_date'])
935
- freq = item['frequency_days']
936
-
937
- # Project next 90 days
938
- next_date = last + timedelta(days=freq)
939
- while next_date <= today + timedelta(days=90):
940
- if next_date >= today:
941
- projected_events.append({
942
- 'date': str(next_date),
943
- 'merchant': item['merchant'],
944
- 'amount': item['amount'],
945
- 'is_income': item['is_income'],
946
- 'frequency': item['frequency'],
947
- 'status': item['status'],
948
- 'category': item['category'],
949
- })
950
- next_date += timedelta(days=freq)
951
-
952
- projected_events.sort(key=lambda x: x['date'])
953
-
954
- return {
955
- 'recurring_expenses': recurring,
956
- 'recurring_income': income,
957
- 'projected_events': projected_events,
958
- 'total_transactions': len(all_transactions),
959
- 'analysis_period': {
960
- 'start': str(start_date),
961
- 'end': str(end_date),
962
- 'total_transactions': len(all_transactions),
963
- }
964
- }
965
 
966
- except Exception as e:
967
- print(f"Recurring transaction error: {e}", flush=True)
968
- return {
969
- 'recurring_expenses': [],
970
- 'recurring_income': [],
971
- 'projected_events': [],
972
- 'analysis_period': None,
973
- }
 
1
  import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ if os.path.exists("./chroma_db"):
4
+ print("chroma_db/ exists, skipping rebuild.")
5
+ else:
6
+ from langchain_community.document_loaders import TextLoader
7
+ from langchain_text_splitters import MarkdownHeaderTextSplitter
8
+ from langchain_huggingface import HuggingFaceEmbeddings
9
+ from langchain_community.vectorstores import Chroma
10
+
11
+ print("Building new index...")
12
+ loader = TextLoader("./docs/fiscal_knowledge.md", encoding="utf-8")
13
+ docs = loader.load()
14
+ text = docs[0].page_content
15
+ print(f"Loaded {len(text):,} characters")
16
+
17
+ splitter = MarkdownHeaderTextSplitter(
18
+ headers_to_split_on=[("##", "question")],
19
+ strip_headers=False,
20
  )
21
+ chunks = splitter.split_text(text)
22
+ print(f"Split into {len(chunks)} chunks")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
+ embeddings = HuggingFaceEmbeddings(
25
+ model_name="nomic-ai/nomic-embed-text-v1",
26
+ model_kwargs={"trust_remote_code": True},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
+ vectorstore = Chroma.from_documents(
30
+ chunks,
31
+ embedding=embeddings,
32
+ persist_directory="./chroma_db",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  )
34
+ print(f"Indexed {len(chunks)} chunks")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ print("Ready.")