MJ-Prod commited on
Commit
ccd6802
·
1 Parent(s): 04801d6

prod plaidInfo

Browse files
Files changed (2) hide show
  1. app.py +82 -34
  2. plaid_client.py +17 -116
app.py CHANGED
@@ -1,9 +1,8 @@
1
  from dotenv import load_dotenv
2
  load_dotenv()
3
 
4
- from fastapi.responses import StreamingResponse
5
  import json
6
- from fastapi.responses import HTMLResponse
7
  import requests
8
  from fastapi import FastAPI, HTTPException, Depends
9
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -11,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
11
  from pydantic import BaseModel
12
  from fiscal import get_answer, clear_memory, stream_answer
13
  from auth import verify_token
14
- from plaid_client import get_financial_snapshot
15
  import os
16
 
17
  app = FastAPI(title="FISCAL API")
@@ -26,18 +25,47 @@ app.add_middleware(
26
  security = HTTPBearer()
27
  SANDBOX_ACCESS_TOKEN = os.environ["PLAID_ACCESS_TOKEN"]
28
 
 
 
 
 
 
 
 
 
29
  class ChatRequest(BaseModel):
30
  message: str
31
 
32
  class ChatResponse(BaseModel):
33
  answer: str
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
 
37
  @app.get("/health")
38
  def health():
39
  return {"status": "ok", "service": "FISCAL"}
40
 
 
41
  @app.post("/chat", response_model=ChatResponse)
42
  def chat(
43
  request: ChatRequest,
@@ -47,16 +75,17 @@ def chat(
47
  if not user:
48
  raise HTTPException(status_code=401, detail="Invalid or expired token")
49
 
50
- financial_context = get_financial_snapshot(SANDBOX_ACCESS_TOKEN)
 
51
 
52
  answer = get_answer(
53
  message=request.message,
54
  user_id=user["sub"],
55
  financial_context=financial_context,
56
  )
57
-
58
  return ChatResponse(answer=answer)
59
 
 
60
  @app.post("/reset")
61
  def reset(credentials: HTTPAuthorizationCredentials = Depends(security)):
62
  user = verify_token(credentials.credentials)
@@ -77,7 +106,8 @@ def chat_stream(
77
 
78
  financial_context = ""
79
  try:
80
- financial_context = get_financial_snapshot(SANDBOX_ACCESS_TOKEN)
 
81
  financial_context = financial_context.replace("{", "(").replace("}", ")")
82
  except Exception as e:
83
  financial_context = "Bank data temporarily unavailable."
@@ -88,15 +118,54 @@ def chat_stream(
88
  user_id=user["sub"],
89
  financial_context=financial_context,
90
  ):
91
- # Send each chunk as a Server-Sent Event
92
  yield f"data: {json.dumps({'chunk': chunk})}\n\n"
93
  yield "data: [DONE]\n\n"
94
 
95
  return StreamingResponse(generate(), media_type="text/event-stream")
96
 
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  @app.delete("/account")
102
  def delete_account(
@@ -107,37 +176,22 @@ def delete_account(
107
  raise HTTPException(status_code=401, detail="Unauthorized")
108
 
109
  user_id = user["sub"]
110
- supabase_url = os.environ.get("SUPABASE_URL", "")
111
- service_key = os.environ.get("SUPABASE_SERVICE_KEY", "")
112
-
113
- headers = {
114
- "apikey": service_key,
115
- "Authorization": f"Bearer {service_key}",
116
- "Content-Type": "application/json"
117
- }
118
 
119
  try:
120
- # 1. Delete bank connections
121
  requests.delete(
122
- f"{supabase_url}/rest/v1/bank_connections?user_id=eq.{user_id}",
123
- headers=headers
124
  )
125
-
126
- # 2. Delete profile
127
  requests.delete(
128
- f"{supabase_url}/rest/v1/profiles?id=eq.{user_id}",
129
- headers=headers
130
  )
131
-
132
- # 3. Delete auth user via Admin API
133
  response = requests.delete(
134
- f"{supabase_url}/auth/v1/admin/users/{user_id}",
135
- headers=headers
136
  )
137
-
138
  if response.status_code not in [200, 204]:
139
  raise Exception(f"Auth delete failed: {response.text}")
140
-
141
  return {"status": "account deleted"}
142
 
143
  except Exception as e:
@@ -175,22 +229,16 @@ def privacy_policy():
175
  <body style="font-family: sans-serif; max-width: 700px; margin: 50px auto; padding: 20px; line-height: 1.6;">
176
  <h1>FISCAL Privacy Policy</h1>
177
  <p><strong>Last updated: May 2026</strong></p>
178
-
179
  <h2>Information We Collect</h2>
180
  <p>We collect your email address and a hashed password to create and manage your account.</p>
181
-
182
  <h2>Financial Data</h2>
183
  <p>With your permission, FISCAL accesses your bank account data via Plaid to provide personalized financial advice. This data is processed ephemerally — it is never stored in our databases and is only used in the moment to generate your AI response.</p>
184
-
185
  <h2>How We Use Your Data</h2>
186
  <p>Your financial data is shared with our AI provider (Featherless AI) solely to generate your personalized financial responses. It is not stored, sold, or used for advertising.</p>
187
-
188
  <h2>Data Storage</h2>
189
  <p>We store only your email address and account credentials securely via Supabase. Chat messages and financial data are never stored.</p>
190
-
191
  <h2>Data Deletion</h2>
192
  <p>You can delete your account and all associated data at any time from within the FISCAL app under Options → Delete Account.</p>
193
-
194
  <h2>Contact</h2>
195
  <p>For privacy questions, contact us at mjproductions594@gmail.com</p>
196
  </body>
 
1
  from dotenv import load_dotenv
2
  load_dotenv()
3
 
4
+ from fastapi.responses import StreamingResponse, HTMLResponse
5
  import json
 
6
  import requests
7
  from fastapi import FastAPI, HTTPException, Depends
8
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
 
10
  from pydantic import BaseModel
11
  from fiscal import get_answer, clear_memory, stream_answer
12
  from auth import verify_token
13
+ from plaid_client import get_financial_snapshot, create_link_token, exchange_public_token
14
  import os
15
 
16
  app = FastAPI(title="FISCAL API")
 
25
  security = HTTPBearer()
26
  SANDBOX_ACCESS_TOKEN = os.environ["PLAID_ACCESS_TOKEN"]
27
 
28
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
29
+ SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "")
30
+ SUPABASE_HEADERS = {
31
+ "apikey": SERVICE_KEY,
32
+ "Authorization": f"Bearer {SERVICE_KEY}",
33
+ "Content-Type": "application/json"
34
+ }
35
+
36
  class ChatRequest(BaseModel):
37
  message: str
38
 
39
  class ChatResponse(BaseModel):
40
  answer: str
41
 
42
+ class LinkTokenResponse(BaseModel):
43
+ link_token: str
44
+
45
+ class ExchangeTokenRequest(BaseModel):
46
+ public_token: str
47
+
48
+
49
+ def get_user_access_token(user_id: str) -> str:
50
+ """Fetch user's real Plaid access token from Supabase, fallback to sandbox."""
51
+ try:
52
+ resp = requests.get(
53
+ f"{SUPABASE_URL}/rest/v1/bank_connections?user_id=eq.{user_id}&limit=1",
54
+ headers=SUPABASE_HEADERS
55
+ )
56
+ connections = resp.json()
57
+ if connections:
58
+ return connections[0]["plaid_access_token"]
59
+ except Exception as e:
60
+ print(f"Could not fetch user token: {e}")
61
+ return SANDBOX_ACCESS_TOKEN
62
 
63
 
64
  @app.get("/health")
65
  def health():
66
  return {"status": "ok", "service": "FISCAL"}
67
 
68
+
69
  @app.post("/chat", response_model=ChatResponse)
70
  def chat(
71
  request: ChatRequest,
 
75
  if not user:
76
  raise HTTPException(status_code=401, detail="Invalid or expired token")
77
 
78
+ access_token = get_user_access_token(user["sub"])
79
+ financial_context = get_financial_snapshot(access_token)
80
 
81
  answer = get_answer(
82
  message=request.message,
83
  user_id=user["sub"],
84
  financial_context=financial_context,
85
  )
 
86
  return ChatResponse(answer=answer)
87
 
88
+
89
  @app.post("/reset")
90
  def reset(credentials: HTTPAuthorizationCredentials = Depends(security)):
91
  user = verify_token(credentials.credentials)
 
106
 
107
  financial_context = ""
108
  try:
109
+ access_token = get_user_access_token(user["sub"])
110
+ financial_context = get_financial_snapshot(access_token)
111
  financial_context = financial_context.replace("{", "(").replace("}", ")")
112
  except Exception as e:
113
  financial_context = "Bank data temporarily unavailable."
 
118
  user_id=user["sub"],
119
  financial_context=financial_context,
120
  ):
 
121
  yield f"data: {json.dumps({'chunk': chunk})}\n\n"
122
  yield "data: [DONE]\n\n"
123
 
124
  return StreamingResponse(generate(), media_type="text/event-stream")
125
 
126
 
127
+ @app.post("/plaid/create_link_token", response_model=LinkTokenResponse)
128
+ def plaid_create_link_token(
129
+ credentials: HTTPAuthorizationCredentials = Depends(security),
130
+ ):
131
+ user = verify_token(credentials.credentials)
132
+ if not user:
133
+ raise HTTPException(status_code=401, detail="Unauthorized")
134
+ try:
135
+ token = create_link_token(user["sub"])
136
+ return LinkTokenResponse(link_token=token)
137
+ except Exception as e:
138
+ print(f"Link token error: {e}")
139
+ raise HTTPException(status_code=500, detail=str(e))
140
 
141
 
142
+ @app.post("/plaid/exchange_token")
143
+ def plaid_exchange_token(
144
+ request: ExchangeTokenRequest,
145
+ credentials: HTTPAuthorizationCredentials = Depends(security),
146
+ ):
147
+ user = verify_token(credentials.credentials)
148
+ if not user:
149
+ raise HTTPException(status_code=401, detail="Unauthorized")
150
+ try:
151
+ access_token = exchange_public_token(request.public_token)
152
+
153
+ # Save to Supabase
154
+ requests.post(
155
+ f"{SUPABASE_URL}/rest/v1/bank_connections",
156
+ headers=SUPABASE_HEADERS,
157
+ json={
158
+ "user_id": user["sub"],
159
+ "plaid_access_token": access_token,
160
+ "institution_name": "Connected Bank",
161
+ "connected_at": "now()"
162
+ }
163
+ )
164
+ return {"status": "connected"}
165
+ except Exception as e:
166
+ print(f"Exchange token error: {e}")
167
+ raise HTTPException(status_code=500, detail=str(e))
168
+
169
 
170
  @app.delete("/account")
171
  def delete_account(
 
176
  raise HTTPException(status_code=401, detail="Unauthorized")
177
 
178
  user_id = user["sub"]
 
 
 
 
 
 
 
 
179
 
180
  try:
 
181
  requests.delete(
182
+ f"{SUPABASE_URL}/rest/v1/bank_connections?user_id=eq.{user_id}",
183
+ headers=SUPABASE_HEADERS
184
  )
 
 
185
  requests.delete(
186
+ f"{SUPABASE_URL}/rest/v1/profiles?id=eq.{user_id}",
187
+ headers=SUPABASE_HEADERS
188
  )
 
 
189
  response = requests.delete(
190
+ f"{SUPABASE_URL}/auth/v1/admin/users/{user_id}",
191
+ headers=SUPABASE_HEADERS
192
  )
 
193
  if response.status_code not in [200, 204]:
194
  raise Exception(f"Auth delete failed: {response.text}")
 
195
  return {"status": "account deleted"}
196
 
197
  except Exception as e:
 
229
  <body style="font-family: sans-serif; max-width: 700px; margin: 50px auto; padding: 20px; line-height: 1.6;">
230
  <h1>FISCAL Privacy Policy</h1>
231
  <p><strong>Last updated: May 2026</strong></p>
 
232
  <h2>Information We Collect</h2>
233
  <p>We collect your email address and a hashed password to create and manage your account.</p>
 
234
  <h2>Financial Data</h2>
235
  <p>With your permission, FISCAL accesses your bank account data via Plaid to provide personalized financial advice. This data is processed ephemerally — it is never stored in our databases and is only used in the moment to generate your AI response.</p>
 
236
  <h2>How We Use Your Data</h2>
237
  <p>Your financial data is shared with our AI provider (Featherless AI) solely to generate your personalized financial responses. It is not stored, sold, or used for advertising.</p>
 
238
  <h2>Data Storage</h2>
239
  <p>We store only your email address and account credentials securely via Supabase. Chat messages and financial data are never stored.</p>
 
240
  <h2>Data Deletion</h2>
241
  <p>You can delete your account and all associated data at any time from within the FISCAL app under Options → Delete Account.</p>
 
242
  <h2>Contact</h2>
243
  <p>For privacy questions, contact us at mjproductions594@gmail.com</p>
244
  </body>
plaid_client.py CHANGED
@@ -1,118 +1,19 @@
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.model.accounts_balance_get_request import AccountsBalanceGetRequest
9
- from plaid.model.transactions_get_request import TransactionsGetRequest
10
- from plaid.model.transactions_get_request_options import TransactionsGetRequestOptions
11
-
12
- # ---------- Client setup ----------
13
-
14
- def _make_client():
15
- env = os.environ.get("PLAID_ENV", "sandbox")
16
- host = {
17
- "sandbox": "https://sandbox.plaid.com",
18
- "development": "https://development.plaid.com",
19
- "production": "https://production.plaid.com",
20
- }[env]
21
-
22
- config = Configuration(
23
- host=host,
24
- api_key={
25
- "clientId": os.environ["PLAID_CLIENT_ID"], # reads from .env
26
- "secret": os.environ["PLAID_SECRET"]
27
- }
28
  )
29
- return plaid_api.PlaidApi(ApiClient(config))
30
-
31
- plaid_client = _make_client()
32
-
33
-
34
- # ---------- Balances ----------
35
-
36
- def get_balances(access_token: str) -> str:
37
- """Returns formatted balance snapshot for the LLM prompt."""
38
- request = AccountsBalanceGetRequest(access_token=access_token)
39
- response = plaid_client.accounts_balance_get(request)
40
-
41
- lines = ["USER'S CURRENT ACCOUNT BALANCES:"]
42
- for account in response['accounts']:
43
- name = account['name']
44
- subtype = account['subtype'] # 'checking', 'credit card', etc.
45
- current = account['balances']['current']
46
-
47
- if subtype == 'credit card':
48
- limit = account['balances']['limit'] or 0
49
- available = account['balances']['available'] or (limit - current)
50
- lines.append(
51
- f"- {name} (Visa/Credit): "
52
- f"${current:.2f} owing, ${available:.2f} available"
53
- )
54
- else:
55
- lines.append(f"- {name} (Chequing): ${current:.2f}")
56
-
57
- return "\n".join(lines)
58
-
59
-
60
- # ---------- Transactions ----------
61
-
62
- def get_transactions(access_token: str, days: int = 30) -> str:
63
- """Returns formatted transaction summary for the LLM prompt."""
64
- end_date = date.today()
65
- start_date = end_date - timedelta(days=days)
66
-
67
- request = TransactionsGetRequest(
68
- access_token=access_token,
69
- start_date=start_date,
70
- end_date=end_date,
71
- options=TransactionsGetRequestOptions(
72
- count=100,
73
- include_personal_finance_category=True
74
- )
75
- )
76
- response = plaid_client.transactions_get(request)
77
- transactions = response['transactions']
78
-
79
- if not transactions:
80
- return "No transactions found in the last 30 days."
81
-
82
- # Python does the math — never let the LLM calculate
83
- category_totals: dict[str, float] = {}
84
- for txn in transactions:
85
- if txn['amount'] <= 0:
86
- continue # skip deposits/refunds
87
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
88
- category_totals[category] = category_totals.get(category, 0) + txn['amount']
89
-
90
- # Format for prompt
91
- lines = [f"SPENDING SUMMARY (last {days} days):"]
92
- for category, total in sorted(category_totals.items(), key=lambda x: -x[1]):
93
- readable = category.replace("_", " ").title()
94
- lines.append(f"- {readable}: ${total:.2f}")
95
-
96
- lines.append(f"\nTotal spent: ${sum(category_totals.values()):.2f}")
97
- lines.append(f"\nRECENT TRANSACTIONS (last 10):")
98
- for txn in transactions[:10]:
99
- lines.append(
100
- f"- {txn['date']} {txn['name']:<30} ${txn['amount']:.2f}"
101
- )
102
-
103
- return "\n".join(lines)
104
-
105
-
106
- # ---------- Combined snapshot ----------
107
 
108
- def get_financial_snapshot(access_token: str) -> str:
109
- """
110
- Returns full financial context string to inject into the LLM prompt.
111
- Combines balances + transaction summary.
112
- """
113
- try:
114
- balances = get_balances(access_token)
115
- transactions = get_transactions(access_token)
116
- return f"{balances}\n\n{transactions}"
117
- except Exception as e:
118
- return f"Unable to fetch account data: {str(e)}"
 
1
+ from plaid.model.link_token_create_request import LinkTokenCreateRequest
2
+ from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser
3
+ from plaid.model.item_public_token_exchange_request import ItemPublicTokenExchangeRequest
4
+
5
+ def create_link_token(user_id: str) -> str:
6
+ request = LinkTokenCreateRequest(
7
+ user=LinkTokenCreateRequestUser(client_user_id=user_id),
8
+ client_name="FISCAL",
9
+ products=[Products("transactions")],
10
+ country_codes=[CountryCode("CA")],
11
+ language="en",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  )
13
+ response = plaid_client.link_token_create(request)
14
+ return response["link_token"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ def exchange_public_token(public_token: str) -> str:
17
+ request = ItemPublicTokenExchangeRequest(public_token=public_token)
18
+ response = plaid_client.item_public_token_exchange(request)
19
+ return response["access_token"]