| 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: |
| print("Using cached financial context", flush=True) |
| return cached_context |
| |
| context = get_financial_snapshot(access_token) |
| |
| |
| 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() |
| |
| |
| 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") |
|
|
| |
| 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 {"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 |
|
|
| |
| 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'], |
| }) |
|
|
| |
| 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} |