MJ-Prod commited on
Commit
07c7afc
·
1 Parent(s): 6967f37

updatedTrailmode

Browse files
Files changed (4) hide show
  1. app.py +13 -31
  2. fixtures/raw_balances.json +174 -0
  3. fixtures/raw_transactions.json +0 -0
  4. plaid_client.py +288 -670
app.py CHANGED
@@ -526,17 +526,16 @@ def calendar_export(
526
  async def trial_chat_stream(request: Request):
527
  body = await request.json()
528
  message = body.get("message", "")
529
- access_token = body.get("access_token", "")
530
 
531
  if not message:
532
  return {"error": "No message provided"}
533
 
534
  trial_user_id = f"trial-{request.client.host}"
535
 
536
- financial_context = "NO_BANK_DATA"
537
- if access_token:
538
- financial_context = get_cached_financial_context(access_token, trial_user_id)
539
-
540
  def generate():
541
  for token in stream_answer(message, trial_user_id, financial_context=financial_context):
542
  yield f"data: {json.dumps({'chunk': token})}\n\n"
@@ -553,39 +552,22 @@ async def trial_reset(request: Request):
553
 
554
  @app.post("/trial/plaid/create_link_token")
555
  async def trial_create_link_token(request: Request):
556
- trial_user_id = f"trial-{request.client.host}"
557
- from plaid_client import create_link_token
558
- token = create_link_token(trial_user_id)
559
- return {"link_token": token}
560
 
561
  @app.post("/trial/plaid/exchange_token")
562
  async def trial_exchange_token(request: Request):
563
- body = await request.json()
564
- public_token = body.get("public_token", "")
565
-
566
- from plaid_client import plaid_client
567
- from plaid.model.item_public_token_exchange_request import ItemPublicTokenExchangeRequest
568
-
569
- exchange_req = ItemPublicTokenExchangeRequest(public_token=public_token)
570
- exchange_resp = plaid_client.item_public_token_exchange(exchange_req)
571
-
572
- return {"access_token": exchange_resp['access_token']}
573
 
574
 
575
  @app.post("/trial/plaid/chart_data")
576
  async def trial_chart_data(request: Request):
577
- body = await request.json()
578
- access_token = body.get("access_token", "")
579
- if not access_token:
580
- return {"error": "No access token"}
581
- from plaid_client import get_chart_data
582
- return get_chart_data(access_token)
583
 
584
  @app.post("/trial/plaid/recurring")
585
  async def trial_recurring(request: Request):
586
- body = await request.json()
587
- access_token = body.get("access_token", "")
588
- if not access_token:
589
- return {"error": "No access token"}
590
- from plaid_client import get_recurring_transactions
591
- return get_recurring_transactions(access_token)
 
526
  async def trial_chat_stream(request: Request):
527
  body = await request.json()
528
  message = body.get("message", "")
 
529
 
530
  if not message:
531
  return {"error": "No message provided"}
532
 
533
  trial_user_id = f"trial-{request.client.host}"
534
 
535
+ # Trial mode: load fixture data directly
536
+ from plaid_client import get_snapshot_from_fixtures
537
+ financial_context = get_snapshot_from_fixtures()
538
+
539
  def generate():
540
  for token in stream_answer(message, trial_user_id, financial_context=financial_context):
541
  yield f"data: {json.dumps({'chunk': token})}\n\n"
 
552
 
553
  @app.post("/trial/plaid/create_link_token")
554
  async def trial_create_link_token(request: Request):
555
+ # Beta mode: no real Plaid link needed
556
+ return {"link_token": "trial-mock-link-token"}
 
 
557
 
558
  @app.post("/trial/plaid/exchange_token")
559
  async def trial_exchange_token(request: Request):
560
+ # Beta mode: return mock token immediately
561
+ return {"access_token": "trial-mock-access-token"}
 
 
 
 
 
 
 
 
562
 
563
 
564
  @app.post("/trial/plaid/chart_data")
565
  async def trial_chart_data(request: Request):
566
+ from plaid_client import get_chart_data_from_fixtures
567
+ return get_chart_data_from_fixtures()
568
+
 
 
 
569
 
570
  @app.post("/trial/plaid/recurring")
571
  async def trial_recurring(request: Request):
572
+ from plaid_client import get_recurring_from_fixtures
573
+ return get_recurring_from_fixtures()
 
 
 
 
fixtures/raw_balances.json ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "accounts": [
3
+ {
4
+ "account_id": "a5QNQ0qoeOfMoQX5NP7wuwyzDnrQ9bi7aLkd9",
5
+ "balances": {
6
+ "available": 521.23,
7
+ "current": 23.23,
8
+ "limit": 500.0,
9
+ "iso_currency_code": "CAD",
10
+ "unofficial_currency_code": null
11
+ },
12
+ "mask": "0436",
13
+ "name": "RBC Advantage Banking",
14
+ "official_name": "RBC Advantage Banking",
15
+ "type": "depository",
16
+ "subtype": "checking"
17
+ },
18
+ {
19
+ "account_id": "xKZ8ZPNEzbH1Q7L0AYJ9uNde8n3PKoHOjN0DA",
20
+ "balances": {
21
+ "available": 722.07,
22
+ "current": 722.07,
23
+ "limit": 0.0,
24
+ "iso_currency_code": "CAD",
25
+ "unofficial_currency_code": null
26
+ },
27
+ "mask": "0707",
28
+ "name": "RBC Day to Day Savings",
29
+ "official_name": "RBC Day to Day Savings",
30
+ "type": "depository",
31
+ "subtype": "savings"
32
+ },
33
+ {
34
+ "account_id": "r1BjBd0RzbIr184AqmwzhxAbMoLNr3FVAx4yr",
35
+ "balances": {
36
+ "available": 0.0,
37
+ "current": 0.0,
38
+ "limit": 12000.0,
39
+ "iso_currency_code": "CAD",
40
+ "unofficial_currency_code": null
41
+ },
42
+ "mask": "0958",
43
+ "name": "RBC Business Cash Back Mastercard",
44
+ "official_name": "RBC Business Cash Back Mastercard",
45
+ "type": "credit",
46
+ "subtype": "credit card"
47
+ },
48
+ {
49
+ "account_id": "o6aAaXgkzNfvZOykDJbYudj8N3bAaXF4kYexL",
50
+ "balances": {
51
+ "available": 497679.49,
52
+ "current": 497679.49,
53
+ "limit": null,
54
+ "iso_currency_code": "CAD",
55
+ "unofficial_currency_code": null
56
+ },
57
+ "mask": "1001",
58
+ "name": "Mortgage Loan - Conventional",
59
+ "official_name": "Mortgage Loan - Conventional",
60
+ "type": "loan",
61
+ "subtype": "mortgage"
62
+ },
63
+ {
64
+ "account_id": "JXZjZB54opf3NykZKOzpuMe4yRAK8YSmQrZOQ",
65
+ "balances": {
66
+ "available": 0.1,
67
+ "current": 0.1,
68
+ "limit": 0.0,
69
+ "iso_currency_code": "CAD",
70
+ "unofficial_currency_code": null
71
+ },
72
+ "mask": "2769",
73
+ "name": "RBC High Interest eSavings",
74
+ "official_name": "RBC High Interest eSavings",
75
+ "type": "depository",
76
+ "subtype": "savings"
77
+ },
78
+ {
79
+ "account_id": "4VgRge5ZLXI89KO1jaJgUwDQOJ7yK5iREy5Yn",
80
+ "balances": {
81
+ "available": 1360.62,
82
+ "current": 1360.62,
83
+ "limit": 17000.0,
84
+ "iso_currency_code": "CAD",
85
+ "unofficial_currency_code": null
86
+ },
87
+ "mask": "2861",
88
+ "name": "Signature RBC Rewards Visa",
89
+ "official_name": "Signature RBC Rewards Visa",
90
+ "type": "credit",
91
+ "subtype": "credit card"
92
+ },
93
+ {
94
+ "account_id": "Z5rMrmdQNOfoEqrmkbPyuyE3Jz9dV8ib9NBAj",
95
+ "balances": {
96
+ "available": 19941.38,
97
+ "current": 19941.38,
98
+ "limit": null,
99
+ "iso_currency_code": "CAD",
100
+ "unofficial_currency_code": null
101
+ },
102
+ "mask": "3001",
103
+ "name": "Credit Line",
104
+ "official_name": "Credit Line",
105
+ "type": "loan",
106
+ "subtype": "line of credit"
107
+ },
108
+ {
109
+ "account_id": "8VzXz56nqeIm9w137BAQhjgyzRKrOnIxKNeLZ",
110
+ "balances": {
111
+ "available": 1335.36,
112
+ "current": 1335.36,
113
+ "limit": null,
114
+ "iso_currency_code": "CAD",
115
+ "unofficial_currency_code": null
116
+ },
117
+ "mask": "5601",
118
+ "name": "RRSP",
119
+ "official_name": "RRSP",
120
+ "type": "investment",
121
+ "subtype": "rrsp"
122
+ },
123
+ {
124
+ "account_id": "bgPdPRBjNptEv4r5xKn9uEXMQj3pgLSJdBybL",
125
+ "balances": {
126
+ "available": null,
127
+ "current": null,
128
+ "limit": null,
129
+ "iso_currency_code": "CAD",
130
+ "unofficial_currency_code": null
131
+ },
132
+ "mask": "7001",
133
+ "name": "RBC Homeline Plan",
134
+ "official_name": "RBC Homeline Plan",
135
+ "type": "loan",
136
+ "subtype": "mortgage"
137
+ },
138
+ {
139
+ "account_id": "5zgQgO7NDXIZDkKP6anju41Rvgqe75IwdePXA",
140
+ "balances": {
141
+ "available": 0.0,
142
+ "current": 0.0,
143
+ "limit": null,
144
+ "iso_currency_code": "CAD",
145
+ "unofficial_currency_code": null
146
+ },
147
+ "mask": "9001",
148
+ "name": "Credit Line",
149
+ "official_name": "Credit Line",
150
+ "type": "loan",
151
+ "subtype": "line of credit"
152
+ }
153
+ ],
154
+ "item": {
155
+ "item_id": "mdmKm7y8zNHybA8R6wxZuZYqgZnRe6CrQBwKz",
156
+ "webhook": "",
157
+ "error": null,
158
+ "available_products": [
159
+ "balance",
160
+ "signal"
161
+ ],
162
+ "billed_products": [
163
+ "transactions"
164
+ ],
165
+ "consent_expiration_time": null,
166
+ "update_type": "background",
167
+ "institution_id": "ins_39",
168
+ "institution_name": "RBC Royal Bank",
169
+ "products": [
170
+ "transactions"
171
+ ]
172
+ },
173
+ "request_id": "fe94ae6702db125"
174
+ }
fixtures/raw_transactions.json ADDED
The diff for this file is too large to render. See raw diff
 
plaid_client.py CHANGED
@@ -1,5 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
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
@@ -12,9 +29,7 @@ from plaid.model.transactions_get_request_options import TransactionsGetRequestO
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
 
@@ -25,12 +40,11 @@ def _make_client():
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))
@@ -38,12 +52,33 @@ def _make_client():
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']
@@ -52,8 +87,6 @@ def get_balances(access_token: str) -> str:
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
 
@@ -76,38 +109,13 @@ def get_balances(access_token: str) -> str:
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,
@@ -119,7 +127,13 @@ def get_transactions(access_token: str, days: int = 30) -> str:
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."
@@ -133,29 +147,28 @@ def get_transactions(access_token: str, days: int = 30) -> str:
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
@@ -194,95 +207,9 @@ def get_transactions(access_token: str, days: int = 30) -> str:
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:
@@ -296,7 +223,8 @@ def get_financial_snapshot(access_token: str) -> 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
  request = LinkTokenCreateRequest(
@@ -305,11 +233,12 @@ def create_update_link_token(user_id: str, access_token: str) -> str:
305
  country_codes=[CountryCode("CA")],
306
  language="en",
307
  access_token=access_token,
308
- redirect_uri="https://chequeup.ca", # ← add this
309
  )
310
  response = plaid_client.link_token_create(request)
311
  return response["link_token"]
312
 
 
313
  def create_link_token(user_id: str) -> str:
314
  request = LinkTokenCreateRequest(
315
  user=LinkTokenCreateRequestUser(client_user_id=user_id),
@@ -323,443 +252,135 @@ def create_link_token(user_id: str) -> str:
323
  return response["link_token"]
324
 
325
 
326
-
327
  def exchange_public_token(public_token: str) -> str:
328
- """Exchanges a public token for a permanent access token."""
329
  request = ItemPublicTokenExchangeRequest(public_token=public_token)
330
  response = plaid_client.item_public_token_exchange(request)
331
  return response["access_token"]
332
 
333
 
 
 
334
  def get_chart_data(access_token: str) -> dict:
335
  """Returns burn-down velocity chart data based on chequing account."""
336
  end_date = date.today()
337
  start_date = end_date.replace(day=1)
338
 
339
- # Get balances
340
- accounts = []
341
- chequing_balance = 0.0
342
- chequing_account_id = None
343
-
344
  try:
345
  balance_req = AccountsBalanceGetRequest(access_token=access_token)
346
  balance_resp = plaid_client.accounts_balance_get(balance_req)
347
- for account in balance_resp['accounts']:
348
- current = account['balances']['current']
349
- if current is None:
350
- continue
351
- subtype = str(account['subtype'])
352
-
353
- # Find the main chequing account
354
- if subtype == 'checking':
355
- chequing_balance = current
356
- chequing_account_id = account['account_id']
357
-
358
- # Skip mortgage and line of credit from display
359
- if subtype in ['mortgage', 'line of credit']:
360
- continue
361
-
362
- accounts.append({
363
- "name": account['name'],
364
- "type": subtype,
365
- "balance": round(current, 2),
366
- })
367
  except Exception as e:
368
  print(f"Balance error in chart_data: {e}", flush=True)
369
-
370
- # Get transactions
371
- burndown = []
372
- income_total = 0.0
373
- expense_total = 0.0
374
 
375
  try:
376
  request = TransactionsGetRequest(
377
  access_token=access_token,
378
  start_date=start_date,
379
  end_date=end_date,
380
- options=TransactionsGetRequestOptions(
381
- count=300,
382
- include_personal_finance_category=True
383
- )
384
  )
385
  response = plaid_client.transactions_get(request)
386
  transactions = response['transactions']
387
-
388
- # Separate spending by day across chequing + credit cards
389
- daily_net: dict[str, float] = {}
390
-
391
- for txn in transactions:
392
- amount = txn['amount']
393
- txn_date = str(txn['date'])
394
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
395
-
396
- # Skip internal transfers between own accounts
397
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
398
- continue
399
-
400
- # Skip loan payments that are actually payroll
401
- name_lower = txn['name'].lower()
402
- if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
403
- income_total += amount
404
- daily_net[txn_date] = daily_net.get(txn_date, 0) - amount # income adds to balance
405
- continue
406
-
407
- if amount < 0:
408
- # Money coming in (income)
409
- income_total += abs(amount)
410
- daily_net[txn_date] = daily_net.get(txn_date, 0) + abs(amount) # adds to balance
411
- else:
412
- # Money going out (spending)
413
- expense_total += amount
414
- daily_net[txn_date] = daily_net.get(txn_date, 0) - amount # subtracts from balance
415
-
416
- # Reconstruct daily balance working BACKWARD from today's chequing balance
417
- # today_balance = chequing_balance
418
- # yesterday_balance = today_balance - net_change_today
419
- # (because net_change is already applied to get to today's balance)
420
-
421
- daily_balances: dict[str, float] = {}
422
- running = chequing_balance
423
-
424
- # Work backward from today
425
- current = end_date
426
- while current >= start_date:
427
- day_str = str(current)
428
- daily_balances[day_str] = running
429
- # Undo this day's net change to get previous day's balance
430
- net_today = daily_net.get(day_str, 0)
431
- running = running - net_today # undo: if we spent $50 (net=-50), previous day was +50 higher
432
- current -= timedelta(days=1)
433
-
434
- # Now build burndown in forward order
435
- current = start_date
436
- while current <= end_date:
437
- day_str = str(current)
438
- balance = daily_balances.get(day_str, 0)
439
- day_spend = 0
440
-
441
- # Calculate just spending for this day (not net)
442
- for txn in transactions:
443
- if str(txn['date']) == day_str and txn['amount'] > 0:
444
- cat = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
445
- if cat not in ['TRANSFER_IN', 'TRANSFER_OUT']:
446
- day_spend += txn['amount']
447
-
448
- burndown.append({
449
- "date": current.strftime("%b %d"),
450
- "balance": round(balance, 2),
451
- "spent": round(day_spend, 2),
452
- })
453
- current += timedelta(days=1)
454
-
455
  except Exception as e:
456
  print(f"Transaction error in chart_data: {e}", flush=True)
 
457
 
458
- return {
459
- "burndown": burndown,
460
- "income_total": round(income_total, 2),
461
- "expense_total": round(expense_total, 2),
462
- "accounts": accounts,
463
- }
464
- """Returns burn-down velocity chart data."""
465
- end_date = date.today()
466
- start_date = end_date.replace(day=1) # Start of current month
467
-
468
- # Get balances
469
- accounts = []
470
- try:
471
- balance_req = AccountsBalanceGetRequest(access_token=access_token)
472
- balance_resp = plaid_client.accounts_balance_get(balance_req)
473
- for account in balance_resp['accounts']:
474
- current = account['balances']['current']
475
- if current is None:
476
- continue
477
- accounts.append({
478
- "name": account['name'],
479
- "type": str(account['subtype']),
480
- "balance": round(current, 2),
481
- })
482
- except Exception as e:
483
- print(f"Balance error in chart_data: {e}", flush=True)
484
-
485
- # Get transactions for current month
486
- burndown = []
487
- income_total = 0.0
488
- expense_total = 0.0
489
-
490
- try:
491
- request = TransactionsGetRequest(
492
- access_token=access_token,
493
- start_date=start_date,
494
- end_date=end_date,
495
- options=TransactionsGetRequestOptions(
496
- count=200,
497
- include_personal_finance_category=True
498
- )
499
- )
500
- response = plaid_client.transactions_get(request)
501
- transactions = response['transactions']
502
-
503
- # Build daily income and spending
504
- daily_income: dict[str, float] = {}
505
- daily_spending: dict[str, float] = {}
506
-
507
- for txn in transactions:
508
- amount = txn['amount']
509
- txn_date = str(txn['date'])
510
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
511
-
512
- if amount < 0:
513
- income_total += abs(amount)
514
- daily_income[txn_date] = daily_income.get(txn_date, 0) + abs(amount)
515
- continue
516
-
517
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
518
- continue
519
 
520
- name_lower = txn['name'].lower()
521
- if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
522
- income_total += amount
523
- daily_income[txn_date] = daily_income.get(txn_date, 0) + amount
524
- continue
525
-
526
- expense_total += amount
527
- daily_spending[txn_date] = daily_spending.get(txn_date, 0) + amount
528
-
529
- # Build burn-down: start with total income, subtract spending each day
530
- running_balance = income_total
531
- current = start_date
532
- while current <= end_date:
533
- day_str = str(current)
534
- day_spend = daily_spending.get(day_str, 0)
535
- day_income = daily_income.get(day_str, 0)
536
- running_balance = running_balance - day_spend + (day_income if current != start_date else 0)
537
-
538
- burndown.append({
539
- "date": current.strftime("%b %d"),
540
- "balance": round(running_balance, 2),
541
- "spent": round(day_spend, 2),
542
- })
543
- current += timedelta(days=1)
544
 
545
- except Exception as e:
546
- print(f"Transaction error in chart_data: {e}", flush=True)
547
-
548
- return {
549
- "burndown": burndown,
550
- "income_total": round(income_total, 2),
551
- "expense_total": round(expense_total, 2),
552
- "accounts": accounts,
553
- }
554
- """Returns structured JSON for frontend charts."""
555
- end_date = date.today()
556
- start_date = end_date - timedelta(days=30)
557
-
558
- # Get balances (usually ready immediately)
559
- try:
560
- balance_req = AccountsBalanceGetRequest(access_token=access_token)
561
- balance_resp = plaid_client.accounts_balance_get(balance_req)
562
- accounts = []
563
- for account in balance_resp['accounts']:
564
- current = account['balances']['current']
565
- if current is None:
566
- continue
567
- accounts.append({
568
- "name": account['name'],
569
- "type": str(account['subtype']),
570
- "balance": round(current, 2),
571
- })
572
- except Exception as e:
573
- print(f"Balance error in chart_data: {e}", flush=True)
574
- accounts = []
575
-
576
- # Get transactions (may not be ready yet)
577
- category_chart = []
578
- weekly_data = []
579
- income_total = 0.0
580
- expense_total = 0.0
581
-
582
- try:
583
- request = TransactionsGetRequest(
584
- access_token=access_token,
585
- start_date=start_date,
586
- end_date=end_date,
587
- options=TransactionsGetRequestOptions(
588
- count=200,
589
- include_personal_finance_category=True
590
- )
591
- )
592
- response = plaid_client.transactions_get(request)
593
- transactions = response['transactions']
594
-
595
- category_totals: dict[str, float] = {}
596
- daily_spending: dict[str, float] = {}
597
- daily_income: dict[str, float] = {}
598
-
599
- for txn in transactions:
600
- amount = txn['amount']
601
- txn_date = str(txn['date'])
602
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
603
-
604
- if amount < 0:
605
- income_total += abs(amount)
606
- daily_income[txn_date] = daily_income.get(txn_date, 0) + abs(amount)
607
- continue
608
-
609
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
610
- continue
611
-
612
- name_lower = txn['name'].lower()
613
- if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
614
- income_total += amount
615
- daily_income[txn_date] = daily_income.get(txn_date, 0) + amount
616
- continue
617
-
618
- expense_total += amount
619
- readable = category.replace("_", " ").title()
620
- category_totals[readable] = category_totals.get(readable, 0) + amount
621
- daily_spending[txn_date] = daily_spending.get(txn_date, 0) + amount
622
-
623
- category_chart = [
624
- {"name": cat, "value": round(total, 2)}
625
- for cat, total in sorted(category_totals.items(), key=lambda x: -x[1])
626
- ]
627
-
628
- for i in range(4):
629
- week_end = end_date - timedelta(weeks=i)
630
- week_start_d = week_end - timedelta(days=6)
631
- week_label = f"{week_start_d.strftime('%b %d')} - {week_end.strftime('%b %d')}"
632
- week_spending = sum(v for k, v in daily_spending.items() if str(week_start_d) <= k <= str(week_end))
633
- week_income = sum(v for k, v in daily_income.items() if str(week_start_d) <= k <= str(week_end))
634
- weekly_data.append({"week": week_label, "spending": round(week_spending, 2), "income": round(week_income, 2)})
635
- weekly_data.reverse()
636
-
637
- except Exception as e:
638
- print(f"Transaction error in chart_data: {e}", flush=True)
639
 
640
- return {
641
- "category_chart": category_chart,
642
- "weekly_chart": weekly_data,
643
- "income_vs_expenses": {
644
- "income": round(income_total, 2),
645
- "expenses": round(expense_total, 2),
646
- },
647
- "accounts": accounts,
648
- }
649
- """Returns structured JSON for frontend charts."""
650
- end_date = date.today()
651
- start_date = end_date - timedelta(days=30)
652
 
653
- # Get transactions
654
- request = TransactionsGetRequest(
655
- access_token=access_token,
656
- start_date=start_date,
657
- end_date=end_date,
658
- options=TransactionsGetRequestOptions(
659
- count=200,
660
- include_personal_finance_category=True
661
- )
662
- )
663
- response = plaid_client.transactions_get(request)
664
- transactions = response['transactions']
665
 
666
- # Get balances
667
- balance_req = AccountsBalanceGetRequest(access_token=access_token)
668
- balance_resp = plaid_client.accounts_balance_get(balance_req)
 
 
669
 
670
- # 1. Spending by category
671
- category_totals: dict[str, float] = {}
672
  income_total = 0.0
673
  expense_total = 0.0
674
-
675
- # 2. Daily spending for weekly breakdown
676
- daily_spending: dict[str, float] = {}
677
- daily_income: dict[str, float] = {}
678
 
679
  for txn in transactions:
680
  amount = txn['amount']
681
  txn_date = str(txn['date'])
682
  category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
683
 
684
- if amount < 0:
685
- income_total += abs(amount)
686
- daily_income[txn_date] = daily_income.get(txn_date, 0) + abs(amount)
687
- continue
688
-
689
  if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
690
  continue
691
 
692
  name_lower = txn['name'].lower()
693
  if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
694
  income_total += amount
695
- daily_income[txn_date] = daily_income.get(txn_date, 0) + amount
696
  continue
697
 
698
- expense_total += amount
699
- readable = category.replace("_", " ").title()
700
- category_totals[readable] = category_totals.get(readable, 0) + amount
701
- daily_spending[txn_date] = daily_spending.get(txn_date, 0) + amount
702
-
703
- # Build category chart data (sorted by amount)
704
- category_chart = [
705
- {"name": cat, "value": round(total, 2)}
706
- for cat, total in sorted(category_totals.items(), key=lambda x: -x[1])
707
- ]
708
-
709
- # Build weekly chart data (last 4 weeks)
710
- weekly_data = []
711
- for i in range(4):
712
- week_end = end_date - timedelta(weeks=i)
713
- week_start = week_end - timedelta(days=6)
714
- week_label = f"{week_start.strftime('%b %d')} - {week_end.strftime('%b %d')}"
715
-
716
- week_spending = sum(
717
- v for k, v in daily_spending.items()
718
- if str(week_start) <= k <= str(week_end)
719
- )
720
- week_income = sum(
721
- v for k, v in daily_income.items()
722
- if str(week_start) <= k <= str(week_end)
723
- )
724
-
725
- weekly_data.append({
726
- "week": week_label,
727
- "spending": round(week_spending, 2),
728
- "income": round(week_income, 2),
729
- })
730
-
731
- weekly_data.reverse()
732
-
733
- # Build account balances
734
- accounts = []
735
- for account in balance_resp['accounts']:
736
- current = account['balances']['current']
737
- if current is None:
738
- continue
739
- accounts.append({
740
- "name": account['name'],
741
- "type": str(account['subtype']),
742
- "balance": round(current, 2),
743
  })
 
744
 
745
  return {
746
- "category_chart": category_chart,
747
- "weekly_chart": weekly_data,
748
- "income_vs_expenses": {
749
- "income": round(income_total, 2),
750
- "expenses": round(expense_total, 2),
751
- },
752
  "accounts": accounts,
753
  }
754
 
755
 
 
 
756
  def get_recurring_transactions(access_token: str) -> dict:
757
  """Analyze 6 months of transactions to find recurring patterns."""
758
  end_date = date.today()
759
  start_date = end_date - timedelta(days=180)
760
 
761
  try:
762
- # Fetch all transactions for 6 months
763
  all_transactions = []
764
  offset = 0
765
  while True:
@@ -779,15 +400,12 @@ def get_recurring_transactions(access_token: str) -> dict:
779
  break
780
  offset = len(all_transactions)
781
 
782
- # ... rest of the function stays the same ...
783
 
784
  except Exception as e:
785
  error_str = str(e)
786
  print(f"Recurring transaction error: {error_str}", flush=True)
787
-
788
- # Check if it's PRODUCT_NOT_READY
789
  not_ready = 'PRODUCT_NOT_READY' in error_str or 'not yet ready' in error_str
790
-
791
  return {
792
  'recurring_expenses': [],
793
  'recurring_income': [],
@@ -795,174 +413,174 @@ def get_recurring_transactions(access_token: str) -> dict:
795
  'analysis_period': None,
796
  'not_ready': not_ready,
797
  }
798
- """Analyze 6 months of transactions to find recurring patterns."""
799
- end_date = date.today()
800
- start_date = end_date - timedelta(days=180) # 6 months back
801
 
802
- try:
803
- # Fetch all transactions for 6 months
804
- all_transactions = []
805
- offset = 0
806
- while True:
807
- request = TransactionsGetRequest(
808
- access_token=access_token,
809
- start_date=start_date,
810
- end_date=end_date,
811
- options=TransactionsGetRequestOptions(
812
- count=500,
813
- offset=offset,
814
- include_personal_finance_category=True
815
- )
816
- )
817
- response = plaid_client.transactions_get(request)
818
- all_transactions.extend(response['transactions'])
819
- if len(all_transactions) >= response['total_transactions']:
820
- break
821
- offset = len(all_transactions)
822
 
823
- # Group transactions by normalized merchant name
824
- merchant_groups: dict[str, list] = defaultdict(list)
825
-
826
- for txn in all_transactions:
827
- name = txn['name']
828
- amount = txn['amount']
829
- txn_date = txn['date']
830
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
831
-
832
- # Skip transfers
833
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
834
- continue
835
-
836
- # Normalize merchant name (remove numbers, extra spaces)
837
- normalized = re.sub(r'[0-9#]+', '', name).strip()
838
- normalized = re.sub(r'\s+', ' ', normalized).upper()
839
-
840
- merchant_groups[normalized].append({
841
- 'date': str(txn_date),
842
- 'amount': amount,
843
- 'name': name,
844
- 'category': category,
845
- })
846
-
847
- # Detect recurring patterns
848
- recurring = []
849
- income = []
850
-
851
- for merchant, txns in merchant_groups.items():
852
- if len(txns) < 3: # Need at least 3 occurrences
853
- continue
854
-
855
- # Sort by date
856
- txns.sort(key=lambda x: x['date'])
857
-
858
- # Check if amounts are consistent (within 10% variance)
859
- amounts = [t['amount'] for t in txns]
860
- avg_amount = sum(amounts) / len(amounts)
861
- if avg_amount == 0:
862
- continue
863
- variance = max(amounts) - min(amounts)
864
- is_consistent_amount = variance / abs(avg_amount) < 0.15
865
-
866
- if not is_consistent_amount:
867
- continue
868
-
869
- # Detect frequency by analyzing gaps between transactions
870
- dates = [date.fromisoformat(t['date']) for t in txns]
871
- gaps = [(dates[i+1] - dates[i]).days for i in range(len(dates)-1)]
872
- avg_gap = sum(gaps) / len(gaps) if gaps else 0
873
-
874
- # Classify frequency
875
- if 5 <= avg_gap <= 10:
876
- frequency = 'weekly'
877
- freq_days = 7
878
- elif 12 <= avg_gap <= 17:
879
- frequency = 'biweekly'
880
- freq_days = 14
881
- elif 25 <= avg_gap <= 35:
882
- frequency = 'monthly'
883
- freq_days = 30
884
- else:
885
- continue # Irregular — skip
886
-
887
- # Count consecutive months present
888
- months_present = set()
889
- for t in txns:
890
- d = date.fromisoformat(t['date'])
891
- months_present.add(f"{d.year}-{d.month:02d}")
892
-
893
- consecutive = len(months_present)
894
-
895
- # Determine if it's income or expense
896
- is_income = avg_amount < 0 # Negative = money in
897
-
898
- entry = {
899
- 'merchant': txns[0]['name'],
900
- 'normalized_name': merchant,
901
- 'amount': round(abs(avg_amount), 2),
902
- 'frequency': frequency,
903
- 'frequency_days': freq_days,
904
- 'category': txns[0]['category'],
905
- 'consecutive_months': consecutive,
906
- 'last_date': txns[-1]['date'],
907
- 'is_income': is_income,
908
- 'history': [{'date': t['date'], 'amount': t['amount']} for t in txns],
909
- }
910
-
911
- if consecutive >= 5:
912
- entry['status'] = 'confirmed' # 5+ months = add indefinitely
913
- elif consecutive >= 3:
914
- entry['status'] = 'likely'
915
- else:
916
- entry['status'] = 'possible'
917
-
918
- if is_income:
919
- income.append(entry)
920
- else:
921
- recurring.append(entry)
922
-
923
- # Sort by amount descending
924
- recurring.sort(key=lambda x: -x['amount'])
925
- income.sort(key=lambda x: -x['amount'])
926
-
927
- # Project future dates for confirmed recurring items
928
- projected_events = []
929
- today = date.today()
930
-
931
- for item in recurring + income:
932
- last = date.fromisoformat(item['last_date'])
933
- freq = item['frequency_days']
934
-
935
- # Project next 90 days
936
- next_date = last + timedelta(days=freq)
937
- while next_date <= today + timedelta(days=90):
938
- if next_date >= today:
939
- projected_events.append({
940
- 'date': str(next_date),
941
- 'merchant': item['merchant'],
942
- 'amount': item['amount'],
943
- 'is_income': item['is_income'],
944
- 'frequency': item['frequency'],
945
- 'status': item['status'],
946
- 'category': item['category'],
947
- })
948
- next_date += timedelta(days=freq)
949
-
950
- projected_events.sort(key=lambda x: x['date'])
951
 
952
- return {
953
- 'recurring_expenses': recurring,
954
- 'recurring_income': income,
955
- 'projected_events': projected_events,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
  'total_transactions': len(all_transactions),
957
- 'analysis_period': {
958
- 'start': str(start_date),
959
- 'end': str(end_date),
960
- 'total_transactions': len(all_transactions),
961
- }
962
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
964
  except Exception as e:
965
- print(f"Recurring transaction error: {e}", flush=True)
966
  return {
967
  'recurring_expenses': [],
968
  'recurring_income': [],
 
1
+ """
2
+ plaid_client.py — real Plaid + fixture fallback for trial mode
3
+
4
+ Real endpoints (get_financial_snapshot, get_chart_data, get_recurring_transactions)
5
+ hit real Plaid. Trial-mode variants (get_snapshot_from_fixtures, etc.) load from
6
+ fixtures/raw_balances.json and fixtures/raw_transactions.json.
7
+
8
+ Called from app.py:
9
+ - Signed-in users → get_financial_snapshot / get_chart_data / get_recurring_transactions
10
+ - Trial users → get_snapshot_from_fixtures / get_chart_data_from_fixtures / get_recurring_from_fixtures
11
+ """
12
+
13
  import os
14
+ import json
15
  from datetime import date, timedelta
16
+ from pathlib import Path
17
+ from collections import defaultdict
18
+ import re
19
+
20
  from plaid.api import plaid_api
21
  from plaid.configuration import Configuration
22
  from plaid.api_client import ApiClient
 
29
  from plaid.model.link_token_create_request import LinkTokenCreateRequest
30
  from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser
31
  from plaid.model.item_public_token_exchange_request import ItemPublicTokenExchangeRequest
32
+
 
 
33
 
34
  # ---------- Client setup ----------
35
 
 
40
  "development": "https://development.plaid.com",
41
  "production": "https://production.plaid.com",
42
  }[env]
 
43
  config = Configuration(
44
  host=host,
45
  api_key={
46
+ "clientId": os.environ["PLAID_CLIENT_ID"],
47
+ "secret": os.environ["PLAID_SECRET"]
48
  }
49
  )
50
  return plaid_api.PlaidApi(ApiClient(config))
 
52
  plaid_client = _make_client()
53
 
54
 
55
+ # ---------- Fixture loading (trial mode only) ----------
56
+
57
+ FIXTURES_DIR = Path(__file__).parent / "fixtures"
58
+
59
+ def _load_raw_balances():
60
+ path = FIXTURES_DIR / "raw_balances.json"
61
+ if not path.exists():
62
+ raise FileNotFoundError(f"Fixture not found: {path}")
63
+ return json.loads(path.read_text())
64
+
65
+ def _load_raw_transactions():
66
+ path = FIXTURES_DIR / "raw_transactions.json"
67
+ if not path.exists():
68
+ raise FileNotFoundError(f"Fixture not found: {path}")
69
+ return json.loads(path.read_text())
70
+
71
+
72
+ # ---------- REAL: Balances ----------
73
 
74
  def get_balances(access_token: str) -> str:
75
  request = AccountsBalanceGetRequest(access_token=access_token)
76
  response = plaid_client.accounts_balance_get(request)
77
+ return _format_balances(response)
78
 
79
+
80
+ def _format_balances(response) -> str:
81
+ """Shared formatter used by both real and fixture paths."""
82
  lines = ["USER'S CURRENT ACCOUNT BALANCES:"]
83
  for account in response['accounts']:
84
  name = account['name']
 
87
 
88
  if current is None:
89
  continue
 
 
90
  if subtype in ['mortgage', 'line of credit']:
91
  continue
92
 
 
109
  lines.append(f"- {name} ({subtype}): ${current:.2f}")
110
 
111
  return "\n".join(lines)
 
 
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ # ---------- REAL: Transactions ----------
115
 
116
  def get_transactions(access_token: str, days: int = 30) -> str:
117
  end_date = date.today()
118
  start_date = end_date - timedelta(days=days)
 
119
 
120
  request = TransactionsGetRequest(
121
  access_token=access_token,
 
127
  )
128
  )
129
  response = plaid_client.transactions_get(request)
130
+ return _format_transactions(response['transactions'], days)
131
+
132
+
133
+ def _format_transactions(transactions, days: int = 30) -> str:
134
+ """Shared formatter used by both real and fixture paths."""
135
+ end_date = date.today()
136
+ week_start = end_date - timedelta(days=end_date.weekday())
137
 
138
  if not transactions:
139
  return "No transactions found in the last 30 days."
 
147
  for txn in transactions:
148
  amount = txn['amount']
149
  name = txn['name']
150
+ txn_date_val = txn['date']
151
+ if isinstance(txn_date_val, str):
152
+ txn_date = date.fromisoformat(txn_date_val)
153
+ else:
154
+ txn_date = txn_date_val
155
  category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
156
 
 
157
  if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
158
  continue
159
 
 
160
  name_lower = name.lower()
161
  if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
162
  income_total += amount
163
  continue
164
 
 
165
  if category == 'LOAN_PAYMENTS':
166
  continue
167
 
 
168
  if amount < 0:
169
  income_total += abs(amount)
170
  continue
171
 
 
172
  month_total += amount
173
  readable = category.replace("_", " ").title()
174
  category_totals[readable] = category_totals.get(readable, 0) + amount
 
207
  count += 1
208
 
209
  return "\n".join(lines)
 
 
 
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
+ # ---------- REAL: Combined snapshot ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
  def get_financial_snapshot(access_token: str) -> str:
215
  try:
 
223
  return "BANK_REAUTH_REQUIRED"
224
  return "NO_BANK_DATA"
225
 
226
+
227
+ # ---------- REAL: Link tokens ----------
228
 
229
  def create_update_link_token(user_id: str, access_token: str) -> str:
230
  request = LinkTokenCreateRequest(
 
233
  country_codes=[CountryCode("CA")],
234
  language="en",
235
  access_token=access_token,
236
+ redirect_uri="https://chequeup.ca",
237
  )
238
  response = plaid_client.link_token_create(request)
239
  return response["link_token"]
240
 
241
+
242
  def create_link_token(user_id: str) -> str:
243
  request = LinkTokenCreateRequest(
244
  user=LinkTokenCreateRequestUser(client_user_id=user_id),
 
252
  return response["link_token"]
253
 
254
 
 
255
  def exchange_public_token(public_token: str) -> str:
 
256
  request = ItemPublicTokenExchangeRequest(public_token=public_token)
257
  response = plaid_client.item_public_token_exchange(request)
258
  return response["access_token"]
259
 
260
 
261
+ # ---------- REAL: Chart data ----------
262
+
263
  def get_chart_data(access_token: str) -> dict:
264
  """Returns burn-down velocity chart data based on chequing account."""
265
  end_date = date.today()
266
  start_date = end_date.replace(day=1)
267
 
 
 
 
 
 
268
  try:
269
  balance_req = AccountsBalanceGetRequest(access_token=access_token)
270
  balance_resp = plaid_client.accounts_balance_get(balance_req)
271
+ balance_data = balance_resp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  except Exception as e:
273
  print(f"Balance error in chart_data: {e}", flush=True)
274
+ balance_data = {'accounts': []}
 
 
 
 
275
 
276
  try:
277
  request = TransactionsGetRequest(
278
  access_token=access_token,
279
  start_date=start_date,
280
  end_date=end_date,
281
+ options=TransactionsGetRequestOptions(count=300, include_personal_finance_category=True)
 
 
 
282
  )
283
  response = plaid_client.transactions_get(request)
284
  transactions = response['transactions']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  except Exception as e:
286
  print(f"Transaction error in chart_data: {e}", flush=True)
287
+ transactions = []
288
 
289
+ return _build_chart_data(balance_data, transactions, start_date, end_date)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
+ def _build_chart_data(balance_data, transactions, start_date, end_date) -> dict:
293
+ """Shared chart builder used by both real and fixture paths."""
294
+ accounts = []
295
+ chequing_balance = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
297
+ for account in balance_data['accounts']:
298
+ current = account['balances']['current']
299
+ if current is None:
300
+ continue
301
+ subtype = str(account['subtype'])
 
 
 
 
 
 
 
302
 
303
+ if subtype == 'checking':
304
+ chequing_balance = current
305
+ if subtype in ['mortgage', 'line of credit']:
306
+ continue
 
 
 
 
 
 
 
 
307
 
308
+ accounts.append({
309
+ "name": account['name'],
310
+ "type": subtype,
311
+ "balance": round(current, 2),
312
+ })
313
 
314
+ burndown = []
 
315
  income_total = 0.0
316
  expense_total = 0.0
317
+ daily_net: dict[str, float] = {}
 
 
 
318
 
319
  for txn in transactions:
320
  amount = txn['amount']
321
  txn_date = str(txn['date'])
322
  category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
323
 
 
 
 
 
 
324
  if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
325
  continue
326
 
327
  name_lower = txn['name'].lower()
328
  if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']):
329
  income_total += amount
330
+ daily_net[txn_date] = daily_net.get(txn_date, 0) - amount
331
  continue
332
 
333
+ if amount < 0:
334
+ income_total += abs(amount)
335
+ daily_net[txn_date] = daily_net.get(txn_date, 0) + abs(amount)
336
+ else:
337
+ expense_total += amount
338
+ daily_net[txn_date] = daily_net.get(txn_date, 0) - amount
339
+
340
+ daily_balances: dict[str, float] = {}
341
+ running = chequing_balance
342
+ current = end_date
343
+ while current >= start_date:
344
+ day_str = str(current)
345
+ daily_balances[day_str] = running
346
+ net_today = daily_net.get(day_str, 0)
347
+ running = running - net_today
348
+ current -= timedelta(days=1)
349
+
350
+ current = start_date
351
+ while current <= end_date:
352
+ day_str = str(current)
353
+ balance = daily_balances.get(day_str, 0)
354
+ day_spend = 0
355
+ for txn in transactions:
356
+ if str(txn['date']) == day_str and txn['amount'] > 0:
357
+ cat = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
358
+ if cat not in ['TRANSFER_IN', 'TRANSFER_OUT']:
359
+ day_spend += txn['amount']
360
+
361
+ burndown.append({
362
+ "date": current.strftime("%b %d"),
363
+ "balance": round(balance, 2),
364
+ "spent": round(day_spend, 2),
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  })
366
+ current += timedelta(days=1)
367
 
368
  return {
369
+ "burndown": burndown,
370
+ "income_total": round(income_total, 2),
371
+ "expense_total": round(expense_total, 2),
 
 
 
372
  "accounts": accounts,
373
  }
374
 
375
 
376
+ # ---------- REAL: Recurring transactions ----------
377
+
378
  def get_recurring_transactions(access_token: str) -> dict:
379
  """Analyze 6 months of transactions to find recurring patterns."""
380
  end_date = date.today()
381
  start_date = end_date - timedelta(days=180)
382
 
383
  try:
 
384
  all_transactions = []
385
  offset = 0
386
  while True:
 
400
  break
401
  offset = len(all_transactions)
402
 
403
+ return _detect_recurring(all_transactions, start_date, end_date)
404
 
405
  except Exception as e:
406
  error_str = str(e)
407
  print(f"Recurring transaction error: {error_str}", flush=True)
 
 
408
  not_ready = 'PRODUCT_NOT_READY' in error_str or 'not yet ready' in error_str
 
409
  return {
410
  'recurring_expenses': [],
411
  'recurring_income': [],
 
413
  'analysis_period': None,
414
  'not_ready': not_ready,
415
  }
 
 
 
416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
418
+ def _detect_recurring(all_transactions, start_date, end_date) -> dict:
419
+ """Shared recurring detector used by both real and fixture paths."""
420
+ merchant_groups: dict[str, list] = defaultdict(list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
 
422
+ for txn in all_transactions:
423
+ name = txn['name']
424
+ amount = txn['amount']
425
+ txn_date = txn['date']
426
+ category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
427
+
428
+ if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
429
+ continue
430
+
431
+ normalized = re.sub(r'[0-9#]+', '', name).strip()
432
+ normalized = re.sub(r'\s+', ' ', normalized).upper()
433
+
434
+ merchant_groups[normalized].append({
435
+ 'date': str(txn_date),
436
+ 'amount': amount,
437
+ 'name': name,
438
+ 'category': category,
439
+ })
440
+
441
+ recurring = []
442
+ income = []
443
+
444
+ for merchant, txns in merchant_groups.items():
445
+ if len(txns) < 3:
446
+ continue
447
+
448
+ txns.sort(key=lambda x: x['date'])
449
+ amounts = [t['amount'] for t in txns]
450
+ avg_amount = sum(amounts) / len(amounts)
451
+ if avg_amount == 0:
452
+ continue
453
+ variance = max(amounts) - min(amounts)
454
+ if variance / abs(avg_amount) >= 0.15:
455
+ continue
456
+
457
+ dates = [date.fromisoformat(t['date']) for t in txns]
458
+ gaps = [(dates[i+1] - dates[i]).days for i in range(len(dates)-1)]
459
+ avg_gap = sum(gaps) / len(gaps) if gaps else 0
460
+
461
+ if 5 <= avg_gap <= 10:
462
+ frequency = 'weekly'
463
+ freq_days = 7
464
+ elif 12 <= avg_gap <= 17:
465
+ frequency = 'biweekly'
466
+ freq_days = 14
467
+ elif 25 <= avg_gap <= 35:
468
+ frequency = 'monthly'
469
+ freq_days = 30
470
+ else:
471
+ continue
472
+
473
+ months_present = set()
474
+ for t in txns:
475
+ d = date.fromisoformat(t['date'])
476
+ months_present.add(f"{d.year}-{d.month:02d}")
477
+
478
+ consecutive = len(months_present)
479
+ is_income = avg_amount < 0
480
+
481
+ entry = {
482
+ 'merchant': txns[0]['name'],
483
+ 'normalized_name': merchant,
484
+ 'amount': round(abs(avg_amount), 2),
485
+ 'frequency': frequency,
486
+ 'frequency_days': freq_days,
487
+ 'category': txns[0]['category'],
488
+ 'consecutive_months': consecutive,
489
+ 'last_date': txns[-1]['date'],
490
+ 'is_income': is_income,
491
+ 'history': [{'date': t['date'], 'amount': t['amount']} for t in txns],
492
+ }
493
+
494
+ if consecutive >= 5:
495
+ entry['status'] = 'confirmed'
496
+ elif consecutive >= 3:
497
+ entry['status'] = 'likely'
498
+ else:
499
+ entry['status'] = 'possible'
500
+
501
+ if is_income:
502
+ income.append(entry)
503
+ else:
504
+ recurring.append(entry)
505
+
506
+ recurring.sort(key=lambda x: -x['amount'])
507
+ income.sort(key=lambda x: -x['amount'])
508
+
509
+ projected_events = []
510
+ today = date.today()
511
+
512
+ for item in recurring + income:
513
+ last = date.fromisoformat(item['last_date'])
514
+ freq = item['frequency_days']
515
+ next_date = last + timedelta(days=freq)
516
+ while next_date <= today + timedelta(days=90):
517
+ if next_date >= today:
518
+ projected_events.append({
519
+ 'date': str(next_date),
520
+ 'merchant': item['merchant'],
521
+ 'amount': item['amount'],
522
+ 'is_income': item['is_income'],
523
+ 'frequency': item['frequency'],
524
+ 'status': item['status'],
525
+ 'category': item['category'],
526
+ })
527
+ next_date += timedelta(days=freq)
528
+
529
+ projected_events.sort(key=lambda x: x['date'])
530
+
531
+ return {
532
+ 'recurring_expenses': recurring,
533
+ 'recurring_income': income,
534
+ 'projected_events': projected_events,
535
+ 'total_transactions': len(all_transactions),
536
+ 'analysis_period': {
537
+ 'start': str(start_date),
538
+ 'end': str(end_date),
539
  'total_transactions': len(all_transactions),
 
 
 
 
 
540
  }
541
+ }
542
+
543
+
544
+ # ============================================================================
545
+ # TRIAL MODE: fixture-based variants
546
+ # Called by /trial/* endpoints in app.py — no Plaid API calls.
547
+ # ============================================================================
548
+
549
+ def get_snapshot_from_fixtures() -> str:
550
+ """Trial mode: build financial snapshot from fixture data."""
551
+ try:
552
+ balances = _load_raw_balances()
553
+ transactions_data = _load_raw_transactions()
554
+ balance_text = _format_balances(balances)
555
+ transaction_text = _format_transactions(transactions_data['transactions'])
556
+ return f"{balance_text}\n\n{transaction_text}"
557
+ except Exception as e:
558
+ print(f"Fixture snapshot error: {e}", flush=True)
559
+ return "NO_BANK_DATA"
560
 
561
+
562
+ def get_chart_data_from_fixtures() -> dict:
563
+ """Trial mode: build chart data from fixture data."""
564
+ end_date = date.today()
565
+ start_date = end_date.replace(day=1)
566
+ try:
567
+ balances = _load_raw_balances()
568
+ transactions_data = _load_raw_transactions()
569
+ return _build_chart_data(balances, transactions_data['transactions'], start_date, end_date)
570
+ except Exception as e:
571
+ print(f"Fixture chart_data error: {e}", flush=True)
572
+ return {"burndown": [], "income_total": 0, "expense_total": 0, "accounts": []}
573
+
574
+
575
+ def get_recurring_from_fixtures() -> dict:
576
+ """Trial mode: detect recurring patterns from fixture data."""
577
+ end_date = date.today()
578
+ start_date = end_date - timedelta(days=180)
579
+ try:
580
+ transactions_data = _load_raw_transactions()
581
+ return _detect_recurring(transactions_data['transactions'], start_date, end_date)
582
  except Exception as e:
583
+ print(f"Fixture recurring error: {e}", flush=True)
584
  return {
585
  'recurring_expenses': [],
586
  'recurring_income': [],