Fiscal / app.py
MJ-Prod's picture
updated info
2eed001
Raw
History Blame Contribute Delete
16.2 kB
from dotenv import load_dotenv
load_dotenv()
from fastapi.responses import StreamingResponse, HTMLResponse
import json
import requests
from fastapi import FastAPI, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from fiscal import get_answer, clear_memory, stream_answer
from auth import verify_token
from plaid.model.country_code import CountryCode
from plaid.exceptions import ApiException
from plaid_client import get_financial_snapshot, create_link_token, exchange_public_token
import os
import time
_financial_cache: dict = {}
app = FastAPI(title="FISCAL API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
security = HTTPBearer()
SANDBOX_ACCESS_TOKEN = os.environ["PLAID_ACCESS_TOKEN"]
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "")
SUPABASE_HEADERS = {
"apikey": SERVICE_KEY,
"Authorization": f"Bearer {SERVICE_KEY}",
"Content-Type": "application/json"
}
class ChatRequest(BaseModel):
message: str
access_token: str = ""
class ChatResponse(BaseModel):
answer: str
class LinkTokenResponse(BaseModel):
link_token: str
class ExchangeTokenRequest(BaseModel):
public_token: str
@app.get("/")
def root():
return {"status": "ok", "service": "FISCAL"}
def get_user_access_token(user_id: str) -> str:
"""Fetch user's real Plaid access token from Supabase, fallback to sandbox."""
try:
resp = requests.get(
f"{SUPABASE_URL}/rest/v1/bank_connections?user_id=eq.{user_id}&limit=1",
headers=SUPABASE_HEADERS
)
connections = resp.json()
if connections:
return connections[0]["plaid_access_token"]
except Exception as e:
print(f"Could not fetch user token: {e}")
return SANDBOX_ACCESS_TOKEN
@app.get("/health")
def health():
return {"status": "ok", "service": "FISCAL"}
def get_cached_financial_context(access_token: str, user_id: str) -> str:
now = time.time()
if user_id in _financial_cache:
cached_time, cached_context = _financial_cache[user_id]
if now - cached_time < 1800: # 30 minutes
print("Using cached financial context", flush=True)
return cached_context
context = get_financial_snapshot(access_token)
# Only cache successful responses
if context not in ["NO_BANK_DATA", "BANK_REAUTH_REQUIRED"]:
_financial_cache[user_id] = (now, context)
return context
class LoadHistoryRequest(BaseModel):
messages: list
@app.post("/load_history")
def load_history(
request: LoadHistoryRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
from fiscal import _get_memory
memory = _get_memory(user["sub"])
memory.clear()
# Rebuild memory from saved messages
messages = request.messages
for i in range(0, len(messages) - 1, 2):
if i + 1 < len(messages):
human_msg = messages[i].get("text", "")
ai_msg = messages[i + 1].get("text", "")
if human_msg and ai_msg:
memory.chat_memory.add_user_message(human_msg)
memory.chat_memory.add_ai_message(ai_msg)
return {"status": "history loaded"}
@app.post("/chat", response_model=ChatResponse)
def chat(
request: ChatRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
# Use token from request if provided, fallback to sandbox
access_token = request.access_token if request.access_token else SANDBOX_ACCESS_TOKEN
financial_context = get_cached_financial_context(access_token, user["sub"])
print(f"Financial context preview: {financial_context[:100]}", flush=True)
answer = get_answer(
message=request.message,
user_id=user["sub"],
financial_context=financial_context,
)
return ChatResponse(answer=answer)
@app.post("/reset")
def reset(credentials: HTTPAuthorizationCredentials = Depends(security)):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
clear_memory(user["sub"])
return {"status": "conversation cleared"}
@app.post("/chat/stream")
def chat_stream(
request: ChatRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
financial_context = ""
bank_reauth = False
try:
access_token = request.access_token if request.access_token else SANDBOX_ACCESS_TOKEN
print(f"Using access token: {access_token[:20]}...", flush=True)
financial_context = get_cached_financial_context(access_token, user["sub"])
print(f"Financial context preview: {financial_context[:150]}", flush=True)
if financial_context == "BANK_REAUTH_REQUIRED":
bank_reauth = True
financial_context = ""
financial_context = financial_context.replace("{", "(").replace("}", ")")
except Exception as e:
print(f"Plaid fetch error: {e}", flush=True)
financial_context = "Bank data temporarily unavailable."
def generate():
if bank_reauth:
yield f"data: {json.dumps({'chunk': '', 'reauth': True})}\n\n"
yield "data: [DONE]\n\n"
return
for chunk in stream_answer(
message=request.message,
user_id=user["sub"],
financial_context=financial_context,
):
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/plaid/create_link_token", response_model=LinkTokenResponse)
def plaid_create_link_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
try:
token = create_link_token(user["sub"])
return LinkTokenResponse(link_token=token)
except Exception as e:
print(f"Link token error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/plaid/exchange_token")
def plaid_exchange_token(
request: ExchangeTokenRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
try:
access_token = exchange_public_token(request.public_token)
# Return access_token to app — app will save it directly
return {"status": "connected", "access_token": access_token}
except Exception as e:
print(f"Exchange token error: {e}")
raise HTTPException(status_code=500, detail=str(e))
class UpdateLinkTokenRequest(BaseModel):
access_token: str
@app.post("/plaid/update_link_token")
def plaid_update_link_token(
request: UpdateLinkTokenRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
try:
from plaid.model.link_token_create_request import LinkTokenCreateRequest
from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser
from plaid.model.country_code import CountryCode
from plaid_client import plaid_client
req = LinkTokenCreateRequest(
client_name="FISCAL",
country_codes=[CountryCode('CA')],
language='en',
user=LinkTokenCreateRequestUser(client_user_id=user["sub"]),
access_token=request.access_token
)
response = plaid_client.link_token_create(req)
return {"link_token": response['link_token']}
except Exception as e:
print(f"Update link token error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/plaid/chart_data")
def plaid_chart_data(
request: ChatRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
access_token = request.access_token if request.access_token else SANDBOX_ACCESS_TOKEN
try:
from plaid_client import get_chart_data
data = get_chart_data(access_token)
return data
except Exception as e:
print(f"Chart data error: {e}", flush=True)
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/account")
def delete_account(
credentials: HTTPAuthorizationCredentials = Depends(security),
):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
user_id = user["sub"]
try:
requests.delete(
f"{SUPABASE_URL}/rest/v1/bank_connections?user_id=eq.{user_id}",
headers=SUPABASE_HEADERS
)
requests.delete(
f"{SUPABASE_URL}/rest/v1/profiles?id=eq.{user_id}",
headers=SUPABASE_HEADERS
)
response = requests.delete(
f"{SUPABASE_URL}/auth/v1/admin/users/{user_id}",
headers=SUPABASE_HEADERS
)
if response.status_code not in [200, 204]:
raise Exception(f"Auth delete failed: {response.text}")
return {"status": "account deleted"}
except Exception as e:
import traceback
print(f"DELETE ACCOUNT ERROR: {type(e).__name__}: {str(e)}")
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.get("/delete", response_class=HTMLResponse)
def delete_info():
return """
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px;">
<h1>Delete Your FISCAL Account</h1>
<p>You can delete your account directly from the FISCAL app:</p>
<ol>
<li>Open the FISCAL app</li>
<li>Tap the settings icon (wrench) in the top right</li>
<li>Tap <strong>Delete Account</strong></li>
<li>Confirm deletion</li>
</ol>
<p>This will permanently delete your account and all associated data.</p>
<p>For assistance, contact us at support@mjproductions.com</p>
</body>
</html>
"""
@app.get("/privacy", response_class=HTMLResponse)
def privacy_policy():
return """
<html>
<head><title>FISCAL Privacy Policy</title></head>
<body style="font-family: sans-serif; max-width: 700px; margin: 50px auto; padding: 20px; line-height: 1.6;">
<h1>FISCAL Privacy Policy</h1>
<p><strong>Last updated: May 2026</strong></p>
<h2>Information We Collect</h2>
<p>We collect your email address and a hashed password to create and manage your account.</p>
<h2>Financial Data</h2>
<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>
<h2>How We Use Your Data</h2>
<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>
<h2>Data Storage</h2>
<p>We store only your email address and account credentials securely via Supabase. Chat messages and financial data are never stored.</p>
<h2>Data Deletion</h2>
<p>You can delete your account and all associated data at any time from within the FISCAL app under Options → Delete Account.</p>
<h2>Contact</h2>
<p>For privacy questions, contact us at mjproductions594@gmail.com</p>
</body>
</html>
"""
@app.post("/plaid/debug_data")
def plaid_debug_data(
request: ChatRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
access_token = request.access_token if request.access_token else SANDBOX_ACCESS_TOKEN
from plaid_client import plaid_client
from plaid.model.accounts_balance_get_request import AccountsBalanceGetRequest
from plaid.model.transactions_get_request import TransactionsGetRequest
from plaid.model.transactions_get_request_options import TransactionsGetRequestOptions
from datetime import date, timedelta
# Raw balances
bal_req = AccountsBalanceGetRequest(access_token=access_token)
bal_resp = plaid_client.accounts_balance_get(bal_req)
accounts_raw = []
for acc in bal_resp['accounts']:
accounts_raw.append({
"name": acc['name'],
"type": str(acc['type']),
"subtype": str(acc['subtype']),
"current": acc['balances']['current'],
"available": acc['balances']['available'],
"limit": acc['balances']['limit'],
})
# Raw transactions (last 10)
end_date = date.today()
start_date = end_date - timedelta(days=30)
txn_req = TransactionsGetRequest(
access_token=access_token,
start_date=start_date,
end_date=end_date,
options=TransactionsGetRequestOptions(count=20, include_personal_finance_category=True)
)
txn_resp = plaid_client.transactions_get(txn_req)
txns_raw = []
for txn in txn_resp['transactions'][:20]:
txns_raw.append({
"date": str(txn['date']),
"name": txn['name'],
"amount": txn['amount'],
"category": txn.get('personal_finance_category', {}).get('primary', 'UNKNOWN'),
"account_id": txn['account_id'],
})
return {
"accounts": accounts_raw,
"transactions": txns_raw,
"total_transactions": txn_resp['total_transactions'],
}
@app.get("/plaid/canadian_banks")
def get_canadian_banks(
credentials: HTTPAuthorizationCredentials = Depends(security),
):
user = verify_token(credentials.credentials)
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
from plaid.model.institutions_search_request import InstitutionsSearchRequest
from plaid.model.institutions_search_request_options import InstitutionsSearchRequestOptions
from plaid.model.products import Products
from plaid_client import plaid_client
banks = []
bank_names = ["Royal Bank", "TD Canada", "Bank of Montreal", "Scotiabank", "CIBC"]
for name in bank_names:
try:
req = InstitutionsSearchRequest(
query=name,
country_codes=[CountryCode('CA')],
products=[Products('transactions')],
options=InstitutionsSearchRequestOptions(
include_optional_metadata=True
)
)
resp = plaid_client.institutions_search(req)
if resp['institutions']:
inst = resp['institutions'][0]
banks.append({
"institution_id": inst['institution_id'],
"name": inst['name'],
"logo": inst.get('logo'),
"color": inst.get('primary_color', '#38bdf8'),
})
except Exception as e:
print(f"Bank search error for {name}: {e}", flush=True)
return {"banks": banks}