MJ-Prod commited on
Commit
25fc4e1
·
1 Parent(s): 520ec35
Files changed (1) hide show
  1. plaid_client.py +965 -28
plaid_client.py CHANGED
@@ -1,36 +1,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.")
 
 
 
 
 
 
 
 
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
+ }