Fiscal / app.py
MJ-Prod's picture
updated RAG_5
66e042c
Raw History Blame Contribute Delete
68.5 kB
from dotenv import load_dotenv
load_dotenv()
from fastapi.responses import StreamingResponse, HTMLResponse
import json
import requests
from fastapi import FastAPI, HTTPException, Depends, Request
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 _build_cashflow_data(all_transactions: list) -> dict:
"""
Shared cash flow data builder.
Returns income + expense breakdown grouped by category and merchant.
Used by both /trial/cashflow/data and /csv/cashflow/data.
"""
from datetime import date, timedelta
from collections import defaultdict
import re
end_date = date.today()
start_date = end_date - timedelta(days=30)
income_total = 0.0
category_data = defaultdict(lambda: {
"total": 0.0,
"merchants": defaultdict(lambda: {"total": 0.0, "count": 0, "transactions": []}),
"count": 0,
})
for txn in all_transactions:
txn_date_val = txn.get('date')
if isinstance(txn_date_val, str):
try:
txn_date = date.fromisoformat(txn_date_val)
except ValueError:
continue
else:
txn_date = txn_date_val
if txn_date < start_date or txn_date > end_date:
continue
amount = txn['amount']
name = txn.get('name', 'Unknown')
category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
continue
name_lower = name.lower()
is_income = amount < 0 or (
category == 'LOAN_PAYMENTS' and
any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages'])
)
if is_income:
income_total += abs(amount)
continue
if category == 'LOAN_PAYMENTS':
continue
normalized_merchant = re.sub(r'[0-9#]+', '', name).strip()
normalized_merchant = re.sub(r'\s+', ' ', normalized_merchant).upper() or name
category_data[category]["total"] += amount
category_data[category]["count"] += 1
category_data[category]["merchants"][normalized_merchant]["total"] += amount
category_data[category]["merchants"][normalized_merchant]["count"] += 1
category_data[category]["merchants"][normalized_merchant]["transactions"].append({
"date": str(txn_date),
"amount": round(amount, 2),
"name": name,
})
categories = []
for cat_name, cat_info in category_data.items():
merchants = []
for merchant_name, merchant_info in cat_info["merchants"].items():
merchants.append({
"name": merchant_name,
"display_name": merchant_info["transactions"][0]["name"] if merchant_info["transactions"] else merchant_name,
"total": round(merchant_info["total"], 2),
"count": merchant_info["count"],
"transactions": sorted(
merchant_info["transactions"],
key=lambda x: x["date"],
reverse=True
)[:10],
})
merchants.sort(key=lambda x: -x["total"])
readable_name = cat_name.replace("_", " ").title()
categories.append({
"name": readable_name,
"raw_name": cat_name,
"total": round(cat_info["total"], 2),
"count": cat_info["count"],
"merchants": merchants,
})
categories.sort(key=lambda x: -x["total"])
return {
"income_total": round(income_total, 2),
"categories": categories,
"date_range": {
"start": str(start_date),
"end": str(end_date),
}
}
def _build_yearsphere_data(all_transactions: list, recurring_data: dict) -> dict:
"""
Shared Year Sphere data builder.
Takes raw transactions + recurring analysis, returns full sphere data shape.
Used by both /trial/yearsphere/data and /csv/yearsphere/data.
"""
from datetime import date
from collections import defaultdict
from calendar import monthrange
recurring_expenses = recurring_data.get("recurring_expenses", [])
recurring_income = recurring_data.get("recurring_income", [])
projected_events = recurring_data.get("projected_events", [])
today = date.today()
# Build the month window: 8 months back through 3 months forward
window = []
for offset in range(-8, 4):
target_month = today.month + offset
target_year = today.year
while target_month < 1:
target_month += 12
target_year -= 1
while target_month > 12:
target_month -= 12
target_year += 1
window.append((target_year, target_month))
# Build per-month recurring index
recurring_by_month = defaultdict(list)
seen_month_names = set()
def add_recurring_occurrence(year, month, day, name, amount, is_income):
dedupe_key = (year, month, name.upper().strip(), day)
if dedupe_key in seen_month_names:
return
seen_month_names.add(dedupe_key)
recurring_by_month[(year, month)].append({
"name": name[:30],
"amount": round(abs(amount), 2),
"date": f"{year:04d}-{month:02d}-{day:02d}",
"is_income": is_income,
})
for item in recurring_expenses:
for hist in item.get("history", []):
hist_date = date.fromisoformat(hist["date"])
add_recurring_occurrence(
hist_date.year, hist_date.month, hist_date.day,
item["merchant"], hist["amount"], is_income=False,
)
for item in recurring_income:
for hist in item.get("history", []):
hist_date = date.fromisoformat(hist["date"])
add_recurring_occurrence(
hist_date.year, hist_date.month, hist_date.day,
item["merchant"], hist["amount"], is_income=True,
)
for ev in projected_events:
ev_date = date.fromisoformat(ev["date"])
if ev_date <= today:
continue
add_recurring_occurrence(
ev_date.year, ev_date.month, ev_date.day,
ev.get("merchant", "Unknown"), ev.get("amount", 0.0),
is_income=ev.get("is_income", False),
)
# Build per-month output
months_out = []
for (year, month) in window:
month_start = date(year, month, 1)
_, last_day = monthrange(year, month)
month_end = date(year, month, last_day)
is_past = month_end < today
is_current = month_start <= today <= month_end
is_future = month_start > today
total_spent = 0.0
total_income = 0.0
daily_spending = defaultdict(float)
daily_transactions = defaultdict(list)
if not is_future:
for txn in all_transactions:
txn_date_val = txn.get('date')
if isinstance(txn_date_val, str):
try:
txn_date = date.fromisoformat(txn_date_val)
except ValueError:
continue
else:
txn_date = txn_date_val
if txn_date < month_start or txn_date > month_end:
continue
amount = txn['amount']
name = txn.get('name', 'Unknown')
category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
name_lower = name.lower()
if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
continue
is_income = amount < 0 or (
category == 'LOAN_PAYMENTS' and
any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages'])
)
daily_transactions[str(txn_date)].append({
"name": name[:30],
"amount": round(abs(amount), 2),
"is_income": is_income,
"category": category,
})
if is_income:
total_income += abs(amount)
else:
total_spent += amount
daily_spending[str(txn_date)] += amount
for rec in recurring_by_month.get((year, month), []):
rec_date_str = rec["date"]
already_in_day = any(
t["name"].upper().strip() == rec["name"].upper().strip()
for t in daily_transactions.get(rec_date_str, [])
)
if not already_in_day:
daily_transactions[rec_date_str].append({
"name": rec["name"],
"amount": rec["amount"],
"is_income": rec["is_income"],
"category": "RECURRING",
})
if is_future:
if rec["is_income"]:
total_income += rec["amount"]
else:
total_spent += rec["amount"]
daily_spending[rec_date_str] += rec["amount"]
month_recurring = recurring_by_month.get((year, month), [])
recurring_income_items = [
{"name": r["name"], "amount": r["amount"], "date": r["date"]}
for r in month_recurring if r["is_income"]
]
recurring_expense_items = [
{"name": r["name"], "amount": r["amount"], "date": r["date"]}
for r in month_recurring if not r["is_income"]
]
if is_future:
total_spent = 0.0
days_in_month = last_day
max_day_spending = max(daily_spending.values()) if daily_spending else 1
daily_data = []
for day_num in range(1, days_in_month + 1):
day_str = str(date(year, month, day_num))
spent = daily_spending.get(day_str, 0.0)
intensity = spent / max_day_spending if max_day_spending > 0 else 0
daily_data.append({
"day": day_num,
"amount": round(spent, 2),
"intensity": round(intensity, 2),
"transactions": daily_transactions.get(day_str, []),
})
first_weekday = month_start.weekday()
first_weekday = (first_weekday + 1) % 7
months_out.append({
"year": year,
"month": month,
"month_name": month_start.strftime("%b").upper(),
"month_name_full": month_start.strftime("%B"),
"total_spent": round(total_spent, 2),
"total_income": round(total_income, 2),
"is_past": is_past,
"is_current": is_current,
"is_future": is_future,
"daily_data": daily_data,
"first_weekday": first_weekday,
"days_in_month": days_in_month,
"recurring_income": sorted(recurring_income_items, key=lambda x: -x["amount"])[:5],
"recurring_expenses": sorted(recurring_expense_items, key=lambda x: -x["amount"])[:10],
})
current_idx = next((i for i, m in enumerate(months_out) if m["is_current"]), 8)
return {
"months": months_out,
"current_month_index": current_idx,
}
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="Cheque Up",
country_codes=[CountryCode('CA')],
language='en',
user=LinkTokenCreateRequestUser(client_user_id=user["sub"]),
access_token=request.access_token,
redirect_uri="https://chequeup.ca", # ← add this
)
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}
@app.post("/plaid/recurring")
def plaid_recurring(
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 get_recurring_transactions
return get_recurring_transactions(access_token)
@app.post("/plaid/calendar_export")
def calendar_export(
request: ChatRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""Generate an .ics file for recurring transactions."""
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 get_recurring_transactions
data = get_recurring_transactions(access_token)
# Build ICS file
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//FISCAL//Calendar//EN",
"CALSCALE:GREGORIAN",
]
for event in data['projected_events']:
if event['status'] != 'confirmed':
continue
d = event['date'].replace('-', '')
prefix = "πŸ’°" if event['is_income'] else "πŸ’Έ"
summary = f"{prefix} {event['merchant']} - ${event['amount']:.2f}"
lines.extend([
"BEGIN:VEVENT",
f"DTSTART;VALUE=DATE:{d}",
f"DTEND;VALUE=DATE:{d}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event['frequency'].title()} - {event['category']}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
from fastapi.responses import Response
return Response(
content="\r\n".join(lines),
media_type="text/calendar",
headers={"Content-Disposition": "attachment; filename=fiscal-calendar.ics"}
)
@app.post("/trial/chat/stream")
async def trial_chat_stream(request: Request):
body = await request.json()
message = body.get("message", "")
if not message:
return {"error": "No message provided"}
trial_user_id = f"trial-{request.client.host}"
# Trial mode: load fixture data directly
from plaid_client import get_snapshot_from_fixtures
financial_context = get_snapshot_from_fixtures()
def generate():
for token in stream_answer(message, trial_user_id, financial_context=financial_context):
yield f"data: {json.dumps({'chunk': token})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/trial/reset")
async def trial_reset(request: Request):
trial_user_id = f"trial-{request.client.host}"
clear_memory(trial_user_id)
return {"status": "ok"}
@app.post("/trial/plaid/create_link_token")
async def trial_create_link_token(request: Request):
# Beta mode: no real Plaid link needed
return {"link_token": "trial-mock-link-token"}
@app.post("/trial/plaid/exchange_token")
async def trial_exchange_token(request: Request):
# Beta mode: return mock token immediately
return {"access_token": "trial-mock-access-token"}
@app.post("/trial/plaid/chart_data")
async def trial_chart_data(request: Request):
from plaid_client import get_chart_data_from_fixtures
return get_chart_data_from_fixtures()
@app.post("/trial/plaid/recurring")
async def trial_recurring(request: Request):
from plaid_client import get_recurring_from_fixtures
return get_recurring_from_fixtures()
@app.post("/trial/prescription/diagnose")
async def trial_prescription_diagnose(request: Request):
"""
Streaming diagnosis for a prescription (account, income, or expense).
Takes prescription context + optional follow-up question.
"""
body = await request.json()
prescription_type = body.get("type", "account") # account | income | expense | loan
prescription_name = body.get("name", "")
prescription_amount = body.get("amount", 0)
prescription_details = body.get("details", "") # frequency, months, subtype, etc.
follow_up_question = body.get("question", "") # optional β€” for follow-ups
trial_user_id = f"trial-{request.client.host}"
# Load fixture context so the LLM sees the full financial picture
from plaid_client import get_snapshot_from_fixtures
financial_context = get_snapshot_from_fixtures()
# Build the diagnosis prompt
if follow_up_question:
# Follow-up: user is asking about the already-diagnosed prescription
prompt = f"""The user previously asked you to diagnose their {prescription_type}: "{prescription_name}" ({prescription_details}, currently ${prescription_amount:.2f}).
They're now asking a follow-up question: "{follow_up_question}"
Answer their question directly, keeping the same calm, doctor-patient tone. Reference the specific prescription details where relevant."""
else:
prompt = f"""The user just handed you their {prescription_type} for a check-up: "{prescription_name}" ({prescription_details}, currently ${prescription_amount:.2f}).
This is not generic financial advice. Your value depends on making the user feel: "It noticed something specific about ME." Ground every observation in their actual transaction data.
REQUIREMENTS FOR SPECIFICITY (this is what separates you from a search engine):
- Reference at least 2 specific numbers, dates, or patterns from their financial context
- Use exact amounts and time ranges: "$214 over 60 days," "3 deposits of $500 in July," "withdrew $180 before payday each time"
- Never make statements that could apply to anyone. Avoid: "this is a solid foundation," "consider automatic transfers," "keep up the good work"
- If you can't ground a statement in their specific data, don't say it
REQUIREMENTS FOR CAUTIOUS LANGUAGE:
- Do not overclaim. The absence of large withdrawals is not proof of progress. State what you observe, not what you infer.
- Use hedged phrasing: "based on the last 60 days," "from what I can see," "your balance has stayed around $X" (not "you're doing great with savings")
- If you only see limited data, say so: "I only see about 60 days of activity here, so I can't tell yet if this is a longer trend"
- Prefer observation over judgment. "Your deposits and withdrawals have roughly balanced" is better than "you're managing well."
STRUCTURE (adapt as needed β€” don't force every section):
**What I see** β€” Specific numbers and patterns from THEIR data. What has actually happened in this {prescription_type} recently?
**What it might mean** β€” Cautiously interpret. Use hedged language when inferring.
**What you could consider** β€” 1-2 specific next steps grounded in their actual pattern (not generic advice). If nothing specific to suggest, say so honestly.
**What's going well** β€” Name something specific and observable that's working, even if small.
Then, at the end, present clear next actions using this EXACT format (this becomes tap-to-ask buttons in the UI):
[ACTIONS]
- Set a savings goal
- Ask about a specific transaction
- Find money I could safely save
- Set up a reminder
[/ACTIONS]
Include only 2-4 actions that make sense for THIS {prescription_type}. Skip actions that don't fit β€” for example, "Set a savings goal" doesn't apply to a chequing account. If there's no obvious next step, use just "Ask about this account".
Under 200 words for the prose. Use markdown headers (**bold**) for each section. Keep sections short β€” 1-2 sentences each.
CRITICAL: Respond with prose only for the diagnosis body. Do NOT include [CHART_DATA] blocks, JSON, or code fences. The [ACTIONS] block at the end is required."""
def generate():
for token in stream_answer(prompt, trial_user_id, financial_context=financial_context):
yield f"data: {json.dumps({'chunk': token})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/trial/prescription/activity")
async def trial_prescription_activity(request: Request):
"""
Returns per-prescription heartbeat activity data (last 90 days of transactions
related to this prescription).
"""
from plaid_client import _load_raw_transactions
from datetime import date, timedelta
import re
body = await request.json()
prescription_type = body.get("type", "account")
prescription_name = body.get("name", "")
try:
fixture = _load_raw_transactions()
all_transactions = fixture['transactions']
except Exception as e:
print(f"Activity fixture error: {e}", flush=True)
return {"points": [], "total_events": 0}
end_date = date.today()
start_date = end_date - timedelta(days=90)
# Filter transactions relevant to this prescription
relevant = []
name_lower = prescription_name.lower()
for txn in all_transactions:
txn_date_val = txn.get('date')
if isinstance(txn_date_val, str):
txn_date = date.fromisoformat(txn_date_val)
else:
txn_date = txn_date_val
if txn_date < start_date or txn_date > end_date:
continue
merchant = txn.get('name', '').lower()
category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
amount = txn['amount']
# Matching logic per prescription type
is_match = False
if prescription_type in ('income', 'expense'):
# Match merchant name loosely (normalized like recurring detection)
normalized_prescription = re.sub(r'[0-9#]+', '', prescription_name).strip().upper()
normalized_merchant = re.sub(r'[0-9#]+', '', txn.get('name', '')).strip().upper()
if normalized_prescription in normalized_merchant or normalized_merchant in normalized_prescription:
is_match = True
elif prescription_type == 'account':
# For accounts: show all spending activity as the pulse
if category not in ['TRANSFER_IN', 'TRANSFER_OUT'] and amount > 0:
is_match = True
elif prescription_type == 'loan':
# For loans: show loan payments
if category == 'LOAN_PAYMENTS':
is_match = True
if is_match:
relevant.append({
'date': str(txn_date),
'amount': round(amount, 2),
'name': txn.get('name', 'Unknown'),
'is_income': amount < 0,
})
# Group by date for the pulse chart
daily = {}
for r in relevant:
d = r['date']
if d not in daily:
daily[d] = {'date': d, 'total': 0, 'count': 0, 'is_income': r['is_income']}
daily[d]['total'] += abs(r['amount'])
daily[d]['count'] += 1
# Build the pulse array: one point per day over 90 days
points = []
current = start_date
while current <= end_date:
day_str = str(current)
if day_str in daily:
points.append({
'date': day_str,
'value': round(daily[day_str]['total'], 2),
'count': daily[day_str]['count'],
'is_income': daily[day_str]['is_income'],
})
else:
points.append({
'date': day_str,
'value': 0,
'count': 0,
'is_income': False,
})
current += timedelta(days=1)
return {
'points': points,
'total_events': len(relevant),
'date_range': {'start': str(start_date), 'end': str(end_date)},
}
@app.post("/trial/cashflow/data")
async def trial_cashflow_data(request: Request):
"""
Returns income + expense breakdown grouped by category and merchant
for the cash flow bubble visualization.
"""
from plaid_client import _load_raw_transactions
from datetime import date, timedelta
from collections import defaultdict
import re
try:
fixture = _load_raw_transactions()
all_transactions = fixture['transactions']
except Exception as e:
print(f"Cashflow fixture error: {e}", flush=True)
return {"income_total": 0, "categories": []}
# Filter to last 30 days
end_date = date.today()
start_date = end_date - timedelta(days=30)
income_total = 0.0
category_data = defaultdict(lambda: {
"total": 0.0,
"merchants": defaultdict(lambda: {"total": 0.0, "count": 0, "transactions": []}),
"count": 0,
})
for txn in all_transactions:
txn_date_val = txn.get('date')
if isinstance(txn_date_val, str):
txn_date = date.fromisoformat(txn_date_val)
else:
txn_date = txn_date_val
if txn_date < start_date or txn_date > end_date:
continue
amount = txn['amount']
name = txn.get('name', 'Unknown')
category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
# Skip transfers
if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
continue
# Identify income (negative amounts or payroll patterns)
name_lower = name.lower()
is_income = amount < 0 or (
category == 'LOAN_PAYMENTS' and
any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages'])
)
if is_income:
income_total += abs(amount)
continue
# Skip other loan payments (mortgage will get its own treatment via recurring)
if category == 'LOAN_PAYMENTS':
continue
# Group into category β†’ merchant
normalized_merchant = re.sub(r'[0-9#]+', '', name).strip()
normalized_merchant = re.sub(r'\s+', ' ', normalized_merchant).upper() or name
category_data[category]["total"] += amount
category_data[category]["count"] += 1
category_data[category]["merchants"][normalized_merchant]["total"] += amount
category_data[category]["merchants"][normalized_merchant]["count"] += 1
category_data[category]["merchants"][normalized_merchant]["transactions"].append({
"date": str(txn_date),
"amount": round(amount, 2),
"name": name,
})
# Convert to sorted list
categories = []
for cat_name, cat_info in category_data.items():
# Build merchant list
merchants = []
for merchant_name, merchant_info in cat_info["merchants"].items():
merchants.append({
"name": merchant_name,
"display_name": merchant_info["transactions"][0]["name"] if merchant_info["transactions"] else merchant_name,
"total": round(merchant_info["total"], 2),
"count": merchant_info["count"],
"transactions": sorted(
merchant_info["transactions"],
key=lambda x: x["date"],
reverse=True
)[:10], # Cap at 10 most recent
})
merchants.sort(key=lambda x: -x["total"])
# Human-readable category name
readable_name = cat_name.replace("_", " ").title()
categories.append({
"name": readable_name,
"raw_name": cat_name,
"total": round(cat_info["total"], 2),
"count": cat_info["count"],
"merchants": merchants,
})
categories.sort(key=lambda x: -x["total"])
return {
"income_total": round(income_total, 2),
"categories": categories,
"date_range": {
"start": str(start_date),
"end": str(end_date),
}
}
@app.post("/trial/cashflow/opinion")
async def trial_cashflow_opinion(request: Request):
"""
Streaming Cheque Up opinion on cash flow at three levels:
- overview: general assessment of the whole month
- category: opinion on a specific spending category
- merchant: opinion on a specific merchant within a category
"""
body = await request.json()
level = body.get("level", "overview") # overview | category | merchant
trial_user_id = f"trial-{request.client.host}"
# Load fixture context
from plaid_client import get_snapshot_from_fixtures
financial_context = get_snapshot_from_fixtures()
if level == "overview":
income_total = body.get("income_total", 0)
top_categories = body.get("top_categories", []) # [{"name": "Food", "total": 340, "count": 12}, ...]
total_spent = sum(c.get("total", 0) for c in top_categories)
remaining = income_total - total_spent
cats_str = "\n".join([f"- {c['name']}: ${c['total']:.0f} ({c['count']} transactions)" for c in top_categories[:6]])
prompt = f"""The user is looking at their cash flow for the last 30 days.
Income this month: ${income_total:.2f}
Top spending categories:
{cats_str}
Total spent: ${total_spent:.2f}
Remaining: ${remaining:.2f}
Give a warm, doctor-patient style overview of the whole month. Cover:
1. What the balance looks like (income vs spending)
2. What's healthy or notable in the split
3. Which category stands out (biggest, or most movable) β€” just observation, not judgment
Keep it under 90 words. Warm, calm, non-judgmental. No advice yet β€” just the "here's what I see" moment. Speak like a doctor reviewing a chart, not a financial advisor giving tips.
CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no lists."""
elif level == "category":
cat_name = body.get("category_name", "")
cat_total = body.get("category_total", 0)
cat_count = body.get("category_count", 0)
income_total = body.get("income_total", 0)
top_merchants = body.get("top_merchants", [])
merchants_str = "\n".join([f"- {m['name']}: ${m['total']:.0f} ({m['count']} times)" for m in top_merchants[:5]])
pct_of_income = (cat_total / income_total * 100) if income_total > 0 else 0
prompt = f"""The user just zoomed into their "{cat_name}" spending.
Category: {cat_name}
Total spent: ${cat_total:.2f} ({cat_count} transactions)
Percentage of monthly income: {pct_of_income:.0f}%
Top merchants in this category:
{merchants_str}
Give a warm, doctor-patient style diagnosis of this category. Cover:
1. Whether the amount is normal, high, or low for this category
2. What the merchant breakdown shows (concentration vs spread)
3. One small observation about the pattern β€” only if genuinely useful
Keep under 90 words. Warm, calm, non-judgmental. Speak like a doctor examining a specific symptom.
CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no lists."""
elif level == "merchant":
merchant_name = body.get("merchant_name", "")
merchant_total = body.get("merchant_total", 0)
merchant_count = body.get("merchant_count", 0)
category_name = body.get("category_name", "")
transactions = body.get("transactions", [])
avg = merchant_total / max(merchant_count, 1)
recent_str = "\n".join([f"- {t['date']}: ${t['amount']:.2f}" for t in transactions[:5]])
prompt = f"""The user just zoomed into a specific merchant: {merchant_name}
Merchant: {merchant_name}
Category: {category_name}
Total spent here: ${merchant_total:.2f}
Number of transactions: {merchant_count}
Average per transaction: ${avg:.2f}
Recent transactions:
{recent_str}
Give a warm, doctor-patient style opinion on this specific merchant. Cover:
1. What the pattern looks like (frequency, size)
2. Whether it's a habit worth noticing (recurring visits, high per-transaction cost)
3. Reassurance if it's fine, or a gentle prompt if there's something worth looking at
Keep under 80 words. Warm, calm, non-judgmental, specific to this merchant.
CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no lists."""
else:
return {"error": "Invalid level"}
def generate():
for token in stream_answer(prompt, trial_user_id, financial_context=financial_context, use_memory=False):
yield f"data: {json.dumps({'chunk': token})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/trial/yearsphere/data")
async def trial_yearsphere_data(request: Request):
"""
Returns 12 months of data for the Year Sphere.
Consumes get_recurring_from_fixtures() β€” same source as the working calendar.
Recurring items are placed on the days they historically hit (past) or are projected to hit (future).
"""
from plaid_client import _load_raw_transactions, get_recurring_from_fixtures
from datetime import date
from collections import defaultdict
from calendar import monthrange
try:
fixture = _load_raw_transactions()
all_transactions = fixture['transactions']
except Exception as e:
print(f"YearSphere fixture error: {e}", flush=True)
return {"months": [], "current_month_index": 0}
# Pull the same recurring data the working calendar uses
try:
recurring_data = get_recurring_from_fixtures()
except Exception as e:
print(f"YearSphere recurring fetch error: {e}", flush=True)
recurring_data = {"recurring_expenses": [], "recurring_income": [], "projected_events": []}
recurring_expenses = recurring_data.get("recurring_expenses", [])
recurring_income = recurring_data.get("recurring_income", [])
projected_events = recurring_data.get("projected_events", [])
today = date.today()
# Build the month window: 8 months back through 3 months forward
window = []
for offset in range(-8, 4):
target_month = today.month + offset
target_year = today.year
while target_month < 1:
target_month += 12
target_year -= 1
while target_month > 12:
target_month -= 12
target_year += 1
window.append((target_year, target_month))
# --- Build a per-month index of recurring occurrences (both historical + projected) ---
# Key: (year, month) -> list of {name, amount, date, is_income}
recurring_by_month: dict = defaultdict(list)
# Track which (year, month, normalized_name) we've seen so we don't double-list
seen_month_names: set = set()
def add_recurring_occurrence(year: int, month: int, day: int, name: str, amount: float, is_income: bool):
# Guard against duplicate entries for the same recurring item in the same month
# (e.g. biweekly items hit twice β€” those are legit, don't dedupe those; dedupe monthly ones)
dedupe_key = (year, month, name.upper().strip(), day)
if dedupe_key in seen_month_names:
return
seen_month_names.add(dedupe_key)
recurring_by_month[(year, month)].append({
"name": name[:30],
"amount": round(abs(amount), 2),
"date": f"{year:04d}-{month:02d}-{day:02d}",
"is_income": is_income,
})
# Add historical occurrences from the recurring_expenses / recurring_income "history" arrays
for item in recurring_expenses:
for hist in item.get("history", []):
hist_date = date.fromisoformat(hist["date"])
add_recurring_occurrence(
hist_date.year, hist_date.month, hist_date.day,
item["merchant"], hist["amount"], is_income=False,
)
for item in recurring_income:
for hist in item.get("history", []):
hist_date = date.fromisoformat(hist["date"])
add_recurring_occurrence(
hist_date.year, hist_date.month, hist_date.day,
item["merchant"], hist["amount"], is_income=True,
)
# Add projected future events
for ev in projected_events:
ev_date = date.fromisoformat(ev["date"])
if ev_date <= today:
continue # Only add projections for future dates
add_recurring_occurrence(
ev_date.year, ev_date.month, ev_date.day,
ev.get("merchant", "Unknown"), ev.get("amount", 0.0),
is_income=ev.get("is_income", False),
)
# --- Build per-month output ---
months_out = []
for (year, month) in window:
month_start = date(year, month, 1)
_, last_day = monthrange(year, month)
month_end = date(year, month, last_day)
is_past = month_end < today
is_current = month_start <= today <= month_end
is_future = month_start > today
total_spent = 0.0
total_income = 0.0
daily_spending = defaultdict(float)
daily_transactions = defaultdict(list)
# Real transactions from fixture (only for past + current)
if not is_future:
for txn in all_transactions:
txn_date_val = txn.get('date')
if isinstance(txn_date_val, str):
txn_date = date.fromisoformat(txn_date_val)
else:
txn_date = txn_date_val
if txn_date < month_start or txn_date > month_end:
continue
amount = txn['amount']
name = txn.get('name', 'Unknown')
category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
name_lower = name.lower()
if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
continue
is_income = amount < 0 or (
category == 'LOAN_PAYMENTS' and
any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages'])
)
daily_transactions[str(txn_date)].append({
"name": name[:30],
"amount": round(abs(amount), 2),
"is_income": is_income,
"category": category,
})
if is_income:
total_income += abs(amount)
else:
total_spent += amount
daily_spending[str(txn_date)] += amount
# Overlay recurring occurrences (historical for past/current, projected for future)
# These are added on top of raw transactions so future months get populated,
# and past months get any recurring occurrences that the raw fixture might miss.
for rec in recurring_by_month.get((year, month), []):
rec_date_str = rec["date"]
already_in_day = any(
t["name"].upper().strip() == rec["name"].upper().strip()
for t in daily_transactions.get(rec_date_str, [])
)
if not already_in_day:
daily_transactions[rec_date_str].append({
"name": rec["name"],
"amount": rec["amount"],
"is_income": rec["is_income"],
"category": "RECURRING",
})
if is_future:
if rec["is_income"]:
total_income += rec["amount"]
else:
total_spent += rec["amount"]
daily_spending[rec_date_str] += rec["amount"]
# For the recurring lists at the bottom of the zoomed view, use the recurring_by_month entries
month_recurring = recurring_by_month.get((year, month), [])
recurring_income_items = [
{"name": r["name"], "amount": r["amount"], "date": r["date"]}
for r in month_recurring if r["is_income"]
]
recurring_expense_items = [
{"name": r["name"], "amount": r["amount"], "date": r["date"]}
for r in month_recurring if not r["is_income"]
]
# Future months: total_spent stays 0 so the sphere stays flat
# (recurring projections were added above but we reset total_spent to 0 for the elevation math)
if is_future:
total_spent = 0.0
# Build daily_data
days_in_month = last_day
max_day_spending = max(daily_spending.values()) if daily_spending else 1
daily_data = []
for day_num in range(1, days_in_month + 1):
day_str = str(date(year, month, day_num))
spent = daily_spending.get(day_str, 0.0)
intensity = spent / max_day_spending if max_day_spending > 0 else 0
daily_data.append({
"day": day_num,
"amount": round(spent, 2),
"intensity": round(intensity, 2),
"transactions": daily_transactions.get(day_str, []),
})
first_weekday = month_start.weekday()
first_weekday = (first_weekday + 1) % 7 # Sun=0
months_out.append({
"year": year,
"month": month,
"month_name": month_start.strftime("%b").upper(),
"month_name_full": month_start.strftime("%B"),
"total_spent": round(total_spent, 2),
"total_income": round(total_income, 2),
"is_past": is_past,
"is_current": is_current,
"is_future": is_future,
"daily_data": daily_data,
"first_weekday": first_weekday,
"days_in_month": days_in_month,
"recurring_income": sorted(recurring_income_items, key=lambda x: -x["amount"])[:5],
"recurring_expenses": sorted(recurring_expense_items, key=lambda x: -x["amount"])[:10],
})
current_idx = next((i for i, m in enumerate(months_out) if m["is_current"]), 8)
return {
"months": months_out,
"current_month_index": current_idx,
}
@app.post("/csv/chart_data")
async def csv_chart_data(request: Request):
"""Chart data from user-uploaded CSV transactions."""
from plaid_client import get_chart_data_from_csv
body = await request.json()
transactions = body.get("transactions", [])
return get_chart_data_from_csv(transactions)
@app.post("/csv/recurring")
async def csv_recurring(request: Request):
"""Recurring pattern detection from user-uploaded CSV transactions."""
from plaid_client import get_recurring_from_csv
body = await request.json()
transactions = body.get("transactions", [])
return get_recurring_from_csv(transactions)
@app.post("/csv/chat/stream")
async def csv_chat_stream(request: Request):
"""Chat with AI using CSV-uploaded transactions as context."""
from plaid_client import get_snapshot_from_csv
body = await request.json()
message = body.get("message", "")
transactions = body.get("transactions", [])
csv_user_id = f"csv-{request.client.host}"
financial_context = get_snapshot_from_csv(transactions)
def generate():
for token in stream_answer(message, csv_user_id, financial_context=financial_context):
yield f"data: {json.dumps({'chunk': token})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/csv/reset")
async def csv_reset(request: Request):
"""Reset chat history for CSV mode."""
csv_user_id = f"csv-{request.client.host}"
reset_history(csv_user_id)
return {"status": "reset"}
@app.post("/csv/prescription/diagnose")
async def csv_prescription_diagnose(request: Request):
"""Streaming diagnosis using CSV transaction context."""
from plaid_client import get_snapshot_from_csv
body = await request.json()
prescription_type = body.get("type", "account")
prescription_name = body.get("name", "")
prescription_amount = body.get("amount", 0)
prescription_details = body.get("details", "")
follow_up_question = body.get("question", "")
transactions = body.get("transactions", [])
csv_user_id = f"csv-{request.client.host}"
financial_context = get_snapshot_from_csv(transactions)
if follow_up_question:
prompt = f"""The user previously asked you to diagnose their {prescription_type}: "{prescription_name}" ({prescription_details}, currently ${prescription_amount:.2f}).
They're now asking a follow-up question: "{follow_up_question}"
Answer their question directly, keeping the same calm, doctor-patient tone. Reference the specific prescription details where relevant."""
else:
prompt = f"""The user just handed you their {prescription_type} for a check-up: "{prescription_name}" ({prescription_details}, currently ${prescription_amount:.2f}).
Give a warm, doctor-patient style diagnosis that covers:
1. **What it is** β€” a plain-language description of this {prescription_type}
2. **The amount** β€” acknowledge the current amount without judgment
3. **How it looks** β€” is this amount healthy, concerning, or somewhere in between? Be honest but calm.
4. **Recent history** β€” mention what you can observe from their recent activity
5. **What could go better** β€” 1-2 specific, actionable suggestions (only if genuinely useful)
6. **Reassurance** β€” even if there's room to improve, name what's going right. If the account is in good shape, celebrate it warmly.
Keep the tone calm, non-judgmental, and specific to this {prescription_type}. Don't lecture. Don't use words like "leak" or "spike." Speak like a doctor who cares about the patient, not a financial advisor trying to upsell them.
Under 200 words. Use markdown headers (**bold**) for each section. Keep sections short β€” 1-2 sentences each.
CRITICAL: Respond with prose only. Do NOT include [CHART_DATA]...[/CHART_DATA] blocks, JSON, code fences, or any structured data."""
def generate():
for token in stream_answer(prompt, csv_user_id, financial_context=financial_context):
yield f"data: {json.dumps({'chunk': token})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/csv/prescription/activity")
async def csv_prescription_activity(request: Request):
"""Activity data for the pulse chart from CSV transactions."""
body = await request.json()
transactions = body.get("transactions", [])
# Reuse the same activity aggregation logic as trial mode
# Group transactions by day, count events
from collections import defaultdict
from datetime import date, timedelta
end_date = date.today()
start_date = end_date - timedelta(days=90)
daily_activity = defaultdict(lambda: {'count': 0, 'total_expense': 0, 'total_income': 0})
for txn in transactions:
txn_date_str = txn.get('date', '')
try:
txn_date = date.fromisoformat(txn_date_str)
except (ValueError, TypeError):
continue
if txn_date < start_date or txn_date > end_date:
continue
amount = txn.get('amount', 0)
category = txn.get('personal_finance_category', {}).get('primary', '')
# Skip transfers to avoid double-counting
if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
continue
key = txn_date.isoformat()
daily_activity[key]['count'] += 1
if amount > 0:
daily_activity[key]['total_expense'] += amount
else:
daily_activity[key]['total_income'] += abs(amount)
# Build activity array
activity = []
current = start_date
while current <= end_date:
key = current.isoformat()
data = daily_activity.get(key, {'count': 0, 'total_expense': 0, 'total_income': 0})
activity.append({
'date': key,
'value': round(data['total_expense'] + data['total_income'], 2),
'count': data['count'],
'is_income': data['total_income'] > data['total_expense'],
})
current += timedelta(days=1)
return {
'activity': activity,
'total_events': sum(a['count'] for a in activity),
}
@app.post("/csv/cashflow/data")
async def csv_cashflow_data(request: Request):
"""Cash flow data from user-uploaded CSV transactions."""
body = await request.json()
transactions = body.get("transactions", [])
return _build_cashflow_data(transactions)
@app.post("/csv/cashflow/opinion")
async def csv_cashflow_opinion(request: Request):
"""AI opinion on cash flow, using CSV transaction context."""
from plaid_client import get_snapshot_from_csv
body = await request.json()
transactions = body.get("transactions", [])
category_focus = body.get("category", None)
csv_user_id = f"csv-{request.client.host}"
financial_context = get_snapshot_from_csv(transactions)
if category_focus:
prompt = f"""The user is looking at their spending in the "{category_focus}" category. Give them a brief, warm perspective on it.
Focus on:
- What patterns you see in this category
- Whether the amount feels reasonable given their overall picture
- One specific observation (not a lecture)
Keep it under 150 words. Calm, non-judgmental tone. No lists β€” just conversational prose."""
else:
prompt = """Give the user a brief overview of their cash flow this month.
Cover:
- Overall picture (income vs. expenses)
- The category that stands out most
- One thing worth noticing (not a lecture, not advice unless it's genuinely useful)
Keep it under 200 words. Warm, doctor-patient tone. Prose only, no lists."""
def generate():
for token in stream_answer(prompt, csv_user_id, financial_context=financial_context):
yield f"data: {json.dumps({'chunk': token})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
# =============================================================================
# NOW REPLACE the /csv/yearsphere/data endpoint with this:
# =============================================================================
@app.post("/csv/yearsphere/data")
async def csv_yearsphere_data(request: Request):
"""Year Sphere data from user-uploaded CSV transactions."""
from plaid_client import get_recurring_from_csv
body = await request.json()
transactions = body.get("transactions", [])
if not transactions:
return {"months": [], "current_month_index": 0}
try:
recurring_data = get_recurring_from_csv(transactions)
except Exception as e:
print(f"CSV yearsphere recurring error: {e}", flush=True)
recurring_data = {"recurring_expenses": [], "recurring_income": [], "projected_events": []}
return _build_yearsphere_data(transactions, recurring_data)
@app.post("/csv/categorize")
async def csv_categorize(request: Request):
"""Categorize PENDING transactions using the AI."""
from fiscal import categorize_transactions_batched
body = await request.json()
transactions = body.get("transactions", [])
if not transactions:
return {"transactions": []}
pending_indices = []
pending_descriptions = []
for i, txn in enumerate(transactions):
current_cat = txn.get('personal_finance_category', {}).get('primary', '')
if current_cat == 'PENDING_CATEGORIZATION':
pending_indices.append(i)
desc = txn.get('raw_description') or txn.get('name', 'Unknown')
pending_descriptions.append(desc)
if not pending_descriptions:
return {
"transactions": transactions,
"categorized_count": 0,
"total_count": len(transactions),
}
print(f"Categorizing {len(pending_descriptions)} pending transactions...", flush=True)
ai_categories = categorize_transactions_batched(pending_descriptions)
for idx, category in zip(pending_indices, ai_categories):
if 'personal_finance_category' not in transactions[idx]:
transactions[idx]['personal_finance_category'] = {}
transactions[idx]['personal_finance_category']['primary'] = category
return {
"transactions": transactions,
"categorized_count": len(pending_descriptions),
"total_count": len(transactions),
}