import os from datetime import date, timedelta from plaid.api import plaid_api from plaid.configuration import Configuration from plaid.api_client import ApiClient from plaid.model.products import Products from plaid.model.country_code import CountryCode from plaid.exceptions import ApiException 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 plaid.model.link_token_create_request import LinkTokenCreateRequest from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser from plaid.model.item_public_token_exchange_request import ItemPublicTokenExchangeRequest # ---------- Client setup ---------- def _make_client(): env = os.environ.get("PLAID_ENV", "sandbox") host = { "sandbox": "https://sandbox.plaid.com", "development": "https://development.plaid.com", "production": "https://production.plaid.com", }[env] config = Configuration( host=host, api_key={ "clientId": os.environ["PLAID_CLIENT_ID"], # reads from .env "secret": os.environ["PLAID_SECRET"] } ) return plaid_api.PlaidApi(ApiClient(config)) plaid_client = _make_client() # ---------- Balances ---------- def get_balances(access_token: str) -> str: request = AccountsBalanceGetRequest(access_token=access_token) response = plaid_client.accounts_balance_get(request) lines = ["USER'S CURRENT ACCOUNT BALANCES:"] for account in response['accounts']: name = account['name'] subtype = str(account['subtype']) current = account['balances']['current'] if current is None: continue # Skip mortgage and line of credit — not relevant for daily budgeting if subtype in ['mortgage', 'line of credit']: continue if subtype == 'credit card': limit = account['balances']['limit'] or 0 owing = current available = limit - current if limit > 0 else 0 lines.append( f"- {name} (Credit Card): " f"${owing:.2f} owing, ${available:.2f} available out of ${limit:.2f} limit" ) elif subtype == 'checking': available = account['balances']['available'] or current lines.append(f"- {name} (Chequing): ${current:.2f} balance, ${available:.2f} available") elif subtype == 'savings': lines.append(f"- {name} (Savings): ${current:.2f}") elif subtype == 'rrsp': lines.append(f"- {name} (RRSP): ${current:.2f}") else: lines.append(f"- {name} ({subtype}): ${current:.2f}") return "\n".join(lines) request = AccountsBalanceGetRequest(access_token=access_token) response = plaid_client.accounts_balance_get(request) lines = ["USER'S CURRENT ACCOUNT BALANCES:"] for account in response['accounts']: name = account['name'] subtype = account['subtype'] current = account['balances']['current'] if current is None: continue if subtype == 'credit card': limit = account['balances']['limit'] or 0 available = account['balances']['available'] or (limit - current) lines.append( f"- {name} (Credit Card): " f"${current:.2f} owing, ${available:.2f} available" ) elif subtype in ['mortgage', 'line of credit']: lines.append(f"- {name} ({subtype}): ${current:.2f} outstanding") else: lines.append(f"- {name} ({subtype}): ${current:.2f}") return "\n".join(lines) # ---------- Transactions ---------- def get_transactions(access_token: str, days: int = 30) -> str: end_date = date.today() start_date = end_date - timedelta(days=days) week_start = end_date - timedelta(days=end_date.weekday()) request = TransactionsGetRequest( access_token=access_token, start_date=start_date, end_date=end_date, options=TransactionsGetRequestOptions( count=200, include_personal_finance_category=True ) ) response = plaid_client.transactions_get(request) transactions = response['transactions'] if not transactions: return "No transactions found in the last 30 days." today_total = 0.0 week_total = 0.0 month_total = 0.0 income_total = 0.0 category_totals: dict[str, float] = {} for txn in transactions: amount = txn['amount'] name = txn['name'] txn_date = txn['date'] category = txn.get('personal_finance_category', {}).get('primary', 'OTHER') # Skip internal transfers between own accounts if category in ['TRANSFER_IN', 'TRANSFER_OUT']: continue # Detect payroll mislabeled as loan payments name_lower = name.lower() if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']): income_total += amount continue # Skip other loan payments (mortgage, LOC) — not daily spending if category == 'LOAN_PAYMENTS': continue # Income (negative = money in) if amount < 0: income_total += abs(amount) continue # Spending month_total += amount readable = category.replace("_", " ").title() category_totals[readable] = category_totals.get(readable, 0) + amount if txn_date >= week_start: week_total += amount if txn_date == end_date: today_total += amount lines = ["FINANCIAL SUMMARY (pre-calculated, do NOT recalculate):"] lines.append(f"Today's spending: ${today_total:.2f}") lines.append(f"This week's spending (since {week_start}): ${week_total:.2f}") lines.append(f"This month's spending (last {days} days): ${month_total:.2f}") if income_total > 0: lines.append(f"Income received (last {days} days): +${income_total:.2f}") lines.append(f"Average daily spending: ${month_total / max(days, 1):.2f}") lines.append("\nSPENDING BY CATEGORY:") for category, total in sorted(category_totals.items(), key=lambda x: -x[1]): pct = (total / month_total * 100) if month_total > 0 else 0 lines.append(f"- {category}: ${total:.2f} ({pct:.0f}%)") lines.append("\nRECENT TRANSACTIONS (last 5):") count = 0 for txn in transactions: if count >= 5: break amount = txn['amount'] category = txn.get('personal_finance_category', {}).get('primary', 'OTHER') if amount <= 0 or category in ['TRANSFER_IN', 'TRANSFER_OUT', 'LOAN_PAYMENTS']: continue lines.append(f" - {txn['date']} {txn['name']}: ${amount:.2f}") count += 1 return "\n".join(lines) end_date = date.today() start_date = end_date - timedelta(days=days) week_start = end_date - timedelta(days=end_date.weekday()) request = TransactionsGetRequest( access_token=access_token, start_date=start_date, end_date=end_date, options=TransactionsGetRequestOptions( count=100, include_personal_finance_category=True ) ) response = plaid_client.transactions_get(request) transactions = response['transactions'] if not transactions: return "No transactions found in the last 30 days." # Pre-calculate everything — never let LLM do math today_total = 0.0 week_total = 0.0 month_total = 0.0 income_total = 0.0 category_totals: dict[str, float] = {} today_transactions: list[str] = [] week_transactions: list[str] = [] for txn in transactions: amount = txn['amount'] name = txn['name'] txn_date = txn['date'] category = txn.get('personal_finance_category', {}).get('primary', 'OTHER') # Income (negative = money in) if amount < 0: income_total += abs(amount) continue # Skip internal transfers if category in ['TRANSFER_IN', 'TRANSFER_OUT']: continue name_lower = name.lower() if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']): income_total += amount continue # Spending totals by time period month_total += amount category_totals[category] = category_totals.get(category, 0) + amount if txn_date >= week_start: week_total += amount week_transactions.append(f" - {txn_date} {name}: ${amount:.2f}") if txn_date == end_date: today_total += amount today_transactions.append(f" - {name}: ${amount:.2f}") # Build compact summary — no raw transaction dumps lines = ["FINANCIAL SUMMARY (pre-calculated, do NOT recalculate):"] lines.append(f"Today's spending: ${today_total:.2f}") lines.append(f"This week's spending (since {week_start}): ${week_total:.2f}") lines.append(f"This month's spending (last {days} days): ${month_total:.2f}") if income_total > 0: lines.append(f"Income received (last {days} days): +${income_total:.2f}") lines.append(f"Average daily spending: ${month_total / max(days, 1):.2f}") lines.append("\nSPENDING BY CATEGORY:") for category, total in sorted(category_totals.items(), key=lambda x: -x[1]): readable = category.replace("_", " ").title() pct = (total / month_total * 100) if month_total > 0 else 0 lines.append(f"- {readable}: ${total:.2f} ({pct:.0f}%)") # Only include recent transactions, limited to 5 lines.append("\nRECENT TRANSACTIONS (last 5):") for txn in transactions[:5]: amount = txn['amount'] if amount <= 0: continue prefix = "-" lines.append(f" {prefix} {txn['date']} {txn['name']}: ${abs(amount):.2f}") return "\n".join(lines) # ---------- Combined snapshot ---------- def get_financial_snapshot(access_token: str) -> str: try: balances = get_balances(access_token) transactions = get_transactions(access_token) return f"{balances}\n\n{transactions}" except Exception as e: error_str = str(e) print(f"Plaid API Exception caught: {error_str}", flush=True) if "ITEM_LOGIN_REQUIRED" in error_str: return "BANK_REAUTH_REQUIRED" return "NO_BANK_DATA" # ---------- Update Mode Link Token ---------- def create_update_link_token(user_id: str, access_token: str) -> str: """ Creates a Plaid Link token in Update Mode for a broken access token. Launches direct bank sync to clear the ITEM_LOGIN_REQUIRED status. """ request = LinkTokenCreateRequest( user=LinkTokenCreateRequestUser(client_user_id=user_id), client_name="FISCAL", country_codes=[CountryCode("CA")], language="en", access_token=access_token # Passing this parameter triggers Update Mode ) response = plaid_client.link_token_create(request) return response["link_token"] def create_link_token(user_id: str) -> str: """Creates a Plaid Link token for the given user.""" request = LinkTokenCreateRequest( user=LinkTokenCreateRequestUser(client_user_id=user_id), client_name="FISCAL", products=[Products("transactions")], country_codes=[CountryCode("CA")], language="en", ) response = plaid_client.link_token_create(request) return response["link_token"] def exchange_public_token(public_token: str) -> str: """Exchanges a public token for a permanent access token.""" request = ItemPublicTokenExchangeRequest(public_token=public_token) response = plaid_client.item_public_token_exchange(request) return response["access_token"] def get_chart_data(access_token: str) -> dict: """Returns burn-down velocity chart data based on chequing account.""" end_date = date.today() start_date = end_date.replace(day=1) # Get balances accounts = [] chequing_balance = 0.0 chequing_account_id = None try: balance_req = AccountsBalanceGetRequest(access_token=access_token) balance_resp = plaid_client.accounts_balance_get(balance_req) for account in balance_resp['accounts']: current = account['balances']['current'] if current is None: continue subtype = str(account['subtype']) # Find the main chequing account if subtype == 'checking': chequing_balance = current chequing_account_id = account['account_id'] # Skip mortgage and line of credit from display if subtype in ['mortgage', 'line of credit']: continue accounts.append({ "name": account['name'], "type": subtype, "balance": round(current, 2), }) except Exception as e: print(f"Balance error in chart_data: {e}", flush=True) # Get transactions burndown = [] income_total = 0.0 expense_total = 0.0 try: request = TransactionsGetRequest( access_token=access_token, start_date=start_date, end_date=end_date, options=TransactionsGetRequestOptions( count=300, include_personal_finance_category=True ) ) response = plaid_client.transactions_get(request) transactions = response['transactions'] # Separate spending by day across chequing + credit cards daily_net: dict[str, float] = {} for txn in transactions: amount = txn['amount'] txn_date = str(txn['date']) category = txn.get('personal_finance_category', {}).get('primary', 'OTHER') # Skip internal transfers between own accounts if category in ['TRANSFER_IN', 'TRANSFER_OUT']: continue # Skip loan payments that are actually payroll name_lower = txn['name'].lower() if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']): income_total += amount daily_net[txn_date] = daily_net.get(txn_date, 0) - amount # income adds to balance continue if amount < 0: # Money coming in (income) income_total += abs(amount) daily_net[txn_date] = daily_net.get(txn_date, 0) + abs(amount) # adds to balance else: # Money going out (spending) expense_total += amount daily_net[txn_date] = daily_net.get(txn_date, 0) - amount # subtracts from balance # Reconstruct daily balance working BACKWARD from today's chequing balance # today_balance = chequing_balance # yesterday_balance = today_balance - net_change_today # (because net_change is already applied to get to today's balance) daily_balances: dict[str, float] = {} running = chequing_balance # Work backward from today current = end_date while current >= start_date: day_str = str(current) daily_balances[day_str] = running # Undo this day's net change to get previous day's balance net_today = daily_net.get(day_str, 0) running = running - net_today # undo: if we spent $50 (net=-50), previous day was +50 higher current -= timedelta(days=1) # Now build burndown in forward order current = start_date while current <= end_date: day_str = str(current) balance = daily_balances.get(day_str, 0) day_spend = 0 # Calculate just spending for this day (not net) for txn in transactions: if str(txn['date']) == day_str and txn['amount'] > 0: cat = txn.get('personal_finance_category', {}).get('primary', 'OTHER') if cat not in ['TRANSFER_IN', 'TRANSFER_OUT']: day_spend += txn['amount'] burndown.append({ "date": current.strftime("%b %d"), "balance": round(balance, 2), "spent": round(day_spend, 2), }) current += timedelta(days=1) except Exception as e: print(f"Transaction error in chart_data: {e}", flush=True) return { "burndown": burndown, "income_total": round(income_total, 2), "expense_total": round(expense_total, 2), "accounts": accounts, } """Returns burn-down velocity chart data.""" end_date = date.today() start_date = end_date.replace(day=1) # Start of current month # Get balances accounts = [] try: balance_req = AccountsBalanceGetRequest(access_token=access_token) balance_resp = plaid_client.accounts_balance_get(balance_req) for account in balance_resp['accounts']: current = account['balances']['current'] if current is None: continue accounts.append({ "name": account['name'], "type": str(account['subtype']), "balance": round(current, 2), }) except Exception as e: print(f"Balance error in chart_data: {e}", flush=True) # Get transactions for current month burndown = [] income_total = 0.0 expense_total = 0.0 try: request = TransactionsGetRequest( access_token=access_token, start_date=start_date, end_date=end_date, options=TransactionsGetRequestOptions( count=200, include_personal_finance_category=True ) ) response = plaid_client.transactions_get(request) transactions = response['transactions'] # Build daily income and spending daily_income: dict[str, float] = {} daily_spending: dict[str, float] = {} for txn in transactions: amount = txn['amount'] txn_date = str(txn['date']) category = txn.get('personal_finance_category', {}).get('primary', 'OTHER') if amount < 0: income_total += abs(amount) daily_income[txn_date] = daily_income.get(txn_date, 0) + abs(amount) continue if category in ['TRANSFER_IN', 'TRANSFER_OUT']: continue name_lower = txn['name'].lower() if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']): income_total += amount daily_income[txn_date] = daily_income.get(txn_date, 0) + amount continue expense_total += amount daily_spending[txn_date] = daily_spending.get(txn_date, 0) + amount # Build burn-down: start with total income, subtract spending each day running_balance = income_total current = start_date while current <= end_date: day_str = str(current) day_spend = daily_spending.get(day_str, 0) day_income = daily_income.get(day_str, 0) running_balance = running_balance - day_spend + (day_income if current != start_date else 0) burndown.append({ "date": current.strftime("%b %d"), "balance": round(running_balance, 2), "spent": round(day_spend, 2), }) current += timedelta(days=1) except Exception as e: print(f"Transaction error in chart_data: {e}", flush=True) return { "burndown": burndown, "income_total": round(income_total, 2), "expense_total": round(expense_total, 2), "accounts": accounts, } """Returns structured JSON for frontend charts.""" end_date = date.today() start_date = end_date - timedelta(days=30) # Get balances (usually ready immediately) try: balance_req = AccountsBalanceGetRequest(access_token=access_token) balance_resp = plaid_client.accounts_balance_get(balance_req) accounts = [] for account in balance_resp['accounts']: current = account['balances']['current'] if current is None: continue accounts.append({ "name": account['name'], "type": str(account['subtype']), "balance": round(current, 2), }) except Exception as e: print(f"Balance error in chart_data: {e}", flush=True) accounts = [] # Get transactions (may not be ready yet) category_chart = [] weekly_data = [] income_total = 0.0 expense_total = 0.0 try: request = TransactionsGetRequest( access_token=access_token, start_date=start_date, end_date=end_date, options=TransactionsGetRequestOptions( count=200, include_personal_finance_category=True ) ) response = plaid_client.transactions_get(request) transactions = response['transactions'] category_totals: dict[str, float] = {} daily_spending: dict[str, float] = {} daily_income: dict[str, float] = {} for txn in transactions: amount = txn['amount'] txn_date = str(txn['date']) category = txn.get('personal_finance_category', {}).get('primary', 'OTHER') if amount < 0: income_total += abs(amount) daily_income[txn_date] = daily_income.get(txn_date, 0) + abs(amount) continue if category in ['TRANSFER_IN', 'TRANSFER_OUT']: continue name_lower = txn['name'].lower() if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']): income_total += amount daily_income[txn_date] = daily_income.get(txn_date, 0) + amount continue expense_total += amount readable = category.replace("_", " ").title() category_totals[readable] = category_totals.get(readable, 0) + amount daily_spending[txn_date] = daily_spending.get(txn_date, 0) + amount category_chart = [ {"name": cat, "value": round(total, 2)} for cat, total in sorted(category_totals.items(), key=lambda x: -x[1]) ] for i in range(4): week_end = end_date - timedelta(weeks=i) week_start_d = week_end - timedelta(days=6) week_label = f"{week_start_d.strftime('%b %d')} - {week_end.strftime('%b %d')}" week_spending = sum(v for k, v in daily_spending.items() if str(week_start_d) <= k <= str(week_end)) week_income = sum(v for k, v in daily_income.items() if str(week_start_d) <= k <= str(week_end)) weekly_data.append({"week": week_label, "spending": round(week_spending, 2), "income": round(week_income, 2)}) weekly_data.reverse() except Exception as e: print(f"Transaction error in chart_data: {e}", flush=True) return { "category_chart": category_chart, "weekly_chart": weekly_data, "income_vs_expenses": { "income": round(income_total, 2), "expenses": round(expense_total, 2), }, "accounts": accounts, } """Returns structured JSON for frontend charts.""" end_date = date.today() start_date = end_date - timedelta(days=30) # Get transactions request = TransactionsGetRequest( access_token=access_token, start_date=start_date, end_date=end_date, options=TransactionsGetRequestOptions( count=200, include_personal_finance_category=True ) ) response = plaid_client.transactions_get(request) transactions = response['transactions'] # Get balances balance_req = AccountsBalanceGetRequest(access_token=access_token) balance_resp = plaid_client.accounts_balance_get(balance_req) # 1. Spending by category category_totals: dict[str, float] = {} income_total = 0.0 expense_total = 0.0 # 2. Daily spending for weekly breakdown daily_spending: dict[str, float] = {} daily_income: dict[str, float] = {} for txn in transactions: amount = txn['amount'] txn_date = str(txn['date']) category = txn.get('personal_finance_category', {}).get('primary', 'OTHER') if amount < 0: income_total += abs(amount) daily_income[txn_date] = daily_income.get(txn_date, 0) + abs(amount) continue if category in ['TRANSFER_IN', 'TRANSFER_OUT']: continue name_lower = txn['name'].lower() if category == 'LOAN_PAYMENTS' and any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages']): income_total += amount daily_income[txn_date] = daily_income.get(txn_date, 0) + amount continue expense_total += amount readable = category.replace("_", " ").title() category_totals[readable] = category_totals.get(readable, 0) + amount daily_spending[txn_date] = daily_spending.get(txn_date, 0) + amount # Build category chart data (sorted by amount) category_chart = [ {"name": cat, "value": round(total, 2)} for cat, total in sorted(category_totals.items(), key=lambda x: -x[1]) ] # Build weekly chart data (last 4 weeks) weekly_data = [] for i in range(4): week_end = end_date - timedelta(weeks=i) week_start = week_end - timedelta(days=6) week_label = f"{week_start.strftime('%b %d')} - {week_end.strftime('%b %d')}" week_spending = sum( v for k, v in daily_spending.items() if str(week_start) <= k <= str(week_end) ) week_income = sum( v for k, v in daily_income.items() if str(week_start) <= k <= str(week_end) ) weekly_data.append({ "week": week_label, "spending": round(week_spending, 2), "income": round(week_income, 2), }) weekly_data.reverse() # Build account balances accounts = [] for account in balance_resp['accounts']: current = account['balances']['current'] if current is None: continue accounts.append({ "name": account['name'], "type": str(account['subtype']), "balance": round(current, 2), }) return { "category_chart": category_chart, "weekly_chart": weekly_data, "income_vs_expenses": { "income": round(income_total, 2), "expenses": round(expense_total, 2), }, "accounts": accounts, }