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 """

Delete Your FISCAL Account

You can delete your account directly from the FISCAL app:

  1. Open the FISCAL app
  2. Tap the settings icon (wrench) in the top right
  3. Tap Delete Account
  4. Confirm deletion

This will permanently delete your account and all associated data.

For assistance, contact us at support@mjproductions.com

""" @app.get("/privacy", response_class=HTMLResponse) def privacy_policy(): return """ FISCAL Privacy Policy

FISCAL Privacy Policy

Last updated: May 2026

Information We Collect

We collect your email address and a hashed password to create and manage your account.

Financial Data

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.

How We Use Your Data

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.

Data Storage

We store only your email address and account credentials securely via Supabase. Chat messages and financial data are never stored.

Data Deletion

You can delete your account and all associated data at any time from within the FISCAL app under Options → Delete Account.

Contact

For privacy questions, contact us at mjproductions594@gmail.com

""" @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}