""" plaid_client.py — real Plaid + fixture fallback for trial mode Real endpoints (get_financial_snapshot, get_chart_data, get_recurring_transactions) hit real Plaid. Trial-mode variants (get_snapshot_from_fixtures, etc.) load from fixtures/raw_balances.json and fixtures/raw_transactions.json. Called from app.py: - Signed-in users → get_financial_snapshot / get_chart_data / get_recurring_transactions - Trial users → get_snapshot_from_fixtures / get_chart_data_from_fixtures / get_recurring_from_fixtures """ import os import json from datetime import date, timedelta from pathlib import Path from collections import defaultdict import re 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"], "secret": os.environ["PLAID_SECRET"] } ) return plaid_api.PlaidApi(ApiClient(config)) plaid_client = _make_client() # ---------- Fixture loading (trial mode only) ---------- FIXTURES_DIR = Path(__file__).parent / "fixtures" def _load_raw_balances(): path = FIXTURES_DIR / "raw_balances.json" if not path.exists(): raise FileNotFoundError(f"Fixture not found: {path}") return json.loads(path.read_text()) from datetime import date, timedelta def _shift_fixture_to_today(fixture): """ Slide all transaction dates forward so the newest transaction lands on today. Preserves relative gaps between transactions so recurring detection still works correctly. """ txns = fixture.get('transactions', []) if not txns: return fixture # Find the latest date in the fixture def parse_date(d): return date.fromisoformat(d) if isinstance(d, str) else d latest = max(parse_date(t['date']) for t in txns) today = date.today() shift_days = (today - latest).days if shift_days <= 0: return fixture # already current or in the future # Shift every transaction date forward by shift_days for t in txns: original = parse_date(t['date']) t['date'] = str(original + timedelta(days=shift_days)) return fixture def _load_raw_transactions(): path = FIXTURES_DIR / "raw_transactions.json" if not path.exists(): raise FileNotFoundError(f"Fixture not found: {path}") fixture = json.loads(path.read_text()) return _shift_fixture_to_today(fixture) # ---------- REAL: Balances ---------- def get_balances(access_token: str) -> str: request = AccountsBalanceGetRequest(access_token=access_token) response = plaid_client.accounts_balance_get(request) return _format_balances(response) def _format_balances(response) -> str: """Shared formatter used by both real and fixture paths.""" 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 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) # ---------- REAL: Transactions ---------- def get_transactions(access_token: str, days: int = 30) -> str: end_date = date.today() start_date = end_date - timedelta(days=days) 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) return _format_transactions(response['transactions'], days) def _format_transactions(transactions, days: int = 30) -> str: """Shared formatter used by both real and fixture paths.""" end_date = date.today() week_start = end_date - timedelta(days=end_date.weekday()) 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_val = txn['date'] if isinstance(txn_date_val, str): txn_date = date.fromisoformat(txn_date_val) else: txn_date = txn_date_val category = txn.get('personal_finance_category', {}).get('primary', 'OTHER') 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 if category == 'LOAN_PAYMENTS': continue if amount < 0: income_total += abs(amount) continue 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) # ---------- REAL: 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" # ---------- REAL: Link tokens ---------- def create_update_link_token(user_id: str, access_token: str) -> str: request = LinkTokenCreateRequest( user=LinkTokenCreateRequestUser(client_user_id=user_id), client_name="Cheque Up", country_codes=[CountryCode("CA")], language="en", access_token=access_token, redirect_uri="https://chequeup.ca", ) response = plaid_client.link_token_create(request) return response["link_token"] def create_link_token(user_id: str) -> str: request = LinkTokenCreateRequest( user=LinkTokenCreateRequestUser(client_user_id=user_id), client_name="Cheque Up", products=[Products("transactions")], country_codes=[CountryCode("CA")], language="en", redirect_uri="https://chequeup.ca", ) response = plaid_client.link_token_create(request) return response["link_token"] def exchange_public_token(public_token: str) -> str: request = ItemPublicTokenExchangeRequest(public_token=public_token) response = plaid_client.item_public_token_exchange(request) return response["access_token"] # ---------- REAL: Chart data ---------- 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) try: balance_req = AccountsBalanceGetRequest(access_token=access_token) balance_resp = plaid_client.accounts_balance_get(balance_req) balance_data = balance_resp except Exception as e: print(f"Balance error in chart_data: {e}", flush=True) balance_data = {'accounts': []} 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'] except Exception as e: print(f"Transaction error in chart_data: {e}", flush=True) transactions = [] return _build_chart_data(balance_data, transactions, start_date, end_date) def _build_chart_data(balance_data, transactions, start_date, end_date) -> dict: """Shared chart builder used by both real and fixture paths.""" accounts = [] chequing_balance = 0.0 for account in balance_data['accounts']: current = account['balances']['current'] if current is None: continue subtype = str(account['subtype']) if subtype == 'checking': chequing_balance = current if subtype in ['mortgage', 'line of credit']: continue accounts.append({ "name": account['name'], "type": subtype, "balance": round(current, 2), }) burndown = [] income_total = 0.0 expense_total = 0.0 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') 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_net[txn_date] = daily_net.get(txn_date, 0) - amount continue if amount < 0: income_total += abs(amount) daily_net[txn_date] = daily_net.get(txn_date, 0) + abs(amount) else: expense_total += amount daily_net[txn_date] = daily_net.get(txn_date, 0) - amount daily_balances: dict[str, float] = {} running = chequing_balance current = end_date while current >= start_date: day_str = str(current) daily_balances[day_str] = running net_today = daily_net.get(day_str, 0) running = running - net_today current -= timedelta(days=1) current = start_date while current <= end_date: day_str = str(current) balance = daily_balances.get(day_str, 0) day_spend = 0 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) return { "burndown": burndown, "income_total": round(income_total, 2), "expense_total": round(expense_total, 2), "accounts": accounts, } # ---------- REAL: Recurring transactions ---------- def get_recurring_transactions(access_token: str) -> dict: """Analyze 6 months of transactions to find recurring patterns.""" end_date = date.today() start_date = end_date - timedelta(days=180) try: all_transactions = [] offset = 0 while True: request = TransactionsGetRequest( access_token=access_token, start_date=start_date, end_date=end_date, options=TransactionsGetRequestOptions( count=500, offset=offset, include_personal_finance_category=True ) ) response = plaid_client.transactions_get(request) all_transactions.extend(response['transactions']) if len(all_transactions) >= response['total_transactions']: break offset = len(all_transactions) return _detect_recurring(all_transactions, start_date, end_date) except Exception as e: error_str = str(e) print(f"Recurring transaction error: {error_str}", flush=True) not_ready = 'PRODUCT_NOT_READY' in error_str or 'not yet ready' in error_str return { 'recurring_expenses': [], 'recurring_income': [], 'projected_events': [], 'analysis_period': None, 'not_ready': not_ready, } def _detect_recurring(all_transactions, start_date, end_date) -> dict: """Shared recurring detector used by both real and fixture paths.""" merchant_groups: dict[str, list] = defaultdict(list) for txn in all_transactions: name = txn['name'] amount = txn['amount'] txn_date = txn['date'] category = txn.get('personal_finance_category', {}).get('primary', 'OTHER') if category in ['TRANSFER_IN', 'TRANSFER_OUT']: continue normalized = re.sub(r'[0-9#]+', '', name).strip() normalized = re.sub(r'\s+', ' ', normalized).upper() merchant_groups[normalized].append({ 'date': str(txn_date), 'amount': amount, 'name': name, 'category': category, }) recurring = [] income = [] for merchant, txns in merchant_groups.items(): if len(txns) < 3: continue txns.sort(key=lambda x: x['date']) amounts = [t['amount'] for t in txns] avg_amount = sum(amounts) / len(amounts) if avg_amount == 0: continue variance = max(amounts) - min(amounts) if variance / abs(avg_amount) >= 0.15: continue dates = [date.fromisoformat(t['date']) for t in txns] gaps = [(dates[i+1] - dates[i]).days for i in range(len(dates)-1)] avg_gap = sum(gaps) / len(gaps) if gaps else 0 if 5 <= avg_gap <= 10: frequency = 'weekly' freq_days = 7 elif 12 <= avg_gap <= 17: frequency = 'biweekly' freq_days = 14 elif 25 <= avg_gap <= 35: frequency = 'monthly' freq_days = 30 else: continue months_present = set() for t in txns: d = date.fromisoformat(t['date']) months_present.add(f"{d.year}-{d.month:02d}") consecutive = len(months_present) is_income = avg_amount < 0 entry = { 'merchant': txns[0]['name'], 'normalized_name': merchant, 'amount': round(abs(avg_amount), 2), 'frequency': frequency, 'frequency_days': freq_days, 'category': txns[0]['category'], 'consecutive_months': consecutive, 'last_date': txns[-1]['date'], 'is_income': is_income, 'history': [{'date': t['date'], 'amount': t['amount']} for t in txns], } if consecutive >= 5: entry['status'] = 'confirmed' elif consecutive >= 3: entry['status'] = 'likely' else: entry['status'] = 'possible' if is_income: income.append(entry) else: recurring.append(entry) recurring.sort(key=lambda x: -x['amount']) income.sort(key=lambda x: -x['amount']) projected_events = [] today = date.today() for item in recurring + income: last = date.fromisoformat(item['last_date']) freq = item['frequency_days'] next_date = last + timedelta(days=freq) while next_date <= today + timedelta(days=90): if next_date >= today: projected_events.append({ 'date': str(next_date), 'merchant': item['merchant'], 'amount': item['amount'], 'is_income': item['is_income'], 'frequency': item['frequency'], 'status': item['status'], 'category': item['category'], }) next_date += timedelta(days=freq) projected_events.sort(key=lambda x: x['date']) return { 'recurring_expenses': recurring, 'recurring_income': income, 'projected_events': projected_events, 'total_transactions': len(all_transactions), 'analysis_period': { 'start': str(start_date), 'end': str(end_date), 'total_transactions': len(all_transactions), } } # ============================================================================ # TRIAL MODE: fixture-based variants # Called by /trial/* endpoints in app.py — no Plaid API calls. # ============================================================================ def get_snapshot_from_fixtures() -> str: """Trial mode: build financial snapshot from fixture data.""" try: balances = _load_raw_balances() transactions_data = _load_raw_transactions() balance_text = _format_balances(balances) transaction_text = _format_transactions(transactions_data['transactions']) return f"{balance_text}\n\n{transaction_text}" except Exception as e: print(f"Fixture snapshot error: {e}", flush=True) return "NO_BANK_DATA" def get_chart_data_from_fixtures() -> dict: """Trial mode: build chart data from fixture data.""" end_date = date.today() start_date = end_date.replace(day=1) try: balances = _load_raw_balances() transactions_data = _load_raw_transactions() return _build_chart_data(balances, transactions_data['transactions'], start_date, end_date) except Exception as e: print(f"Fixture chart_data error: {e}", flush=True) return {"burndown": [], "income_total": 0, "expense_total": 0, "accounts": []} def get_recurring_from_fixtures() -> dict: """Trial mode: detect recurring patterns from fixture data.""" end_date = date.today() start_date = end_date - timedelta(days=180) try: transactions_data = _load_raw_transactions() return _detect_recurring(transactions_data['transactions'], start_date, end_date) except Exception as e: print(f"Fixture recurring error: {e}", flush=True) return { 'recurring_expenses': [], 'recurring_income': [], 'projected_events': [], 'analysis_period': None, } def _build_synthetic_balances_from_transactions(transactions: list) -> dict: """ CSV mode: infer account balances from transaction history. We don't have real balances (CSV doesn't include them), so we estimate: - For each account_id in the transactions, sum credits - debits to get an inferred balance - Use latest transaction as reference point Returns a Plaid-shaped response so downstream formatters work unchanged. """ from collections import defaultdict account_totals = defaultdict(float) account_names = {} latest_dates = {} for txn in transactions: acc_id = txn.get('account_id', 'csv_account_default') amount = txn.get('amount', 0) # Plaid convention: positive = expense (money out), negative = income (money in) # So current balance = sum of (-amount) = money in minus money out account_totals[acc_id] += (-amount) # Track a display name — use account_id or default if acc_id not in account_names: account_names[acc_id] = f"Uploaded Account {acc_id[-4:]}" if len(acc_id) > 4 else "Uploaded Account" # Track latest date txn_date = txn.get('date', '') if acc_id not in latest_dates or txn_date > latest_dates[acc_id]: latest_dates[acc_id] = txn_date # Build a Plaid-shaped accounts response accounts = [] for acc_id, running_total in account_totals.items(): accounts.append({ 'account_id': acc_id, 'name': account_names[acc_id], 'subtype': 'checking', # assume chequing for CSV uploads 'balances': { 'current': round(running_total, 2), 'available': round(running_total, 2), 'limit': None, } }) if not accounts: # Fallback: single generic account accounts.append({ 'account_id': 'csv_account_default', 'name': 'Uploaded Account', 'subtype': 'checking', 'balances': {'current': 0, 'available': 0, 'limit': None} }) return {'accounts': accounts} def get_snapshot_from_csv(transactions: list) -> str: """CSV mode: build financial snapshot from user-uploaded transactions.""" try: balances = _build_synthetic_balances_from_transactions(transactions) balance_text = _format_balances(balances) transaction_text = _format_transactions(transactions) return f"{balance_text}\n\n{transaction_text}" except Exception as e: print(f"CSV snapshot error: {e}", flush=True) return "NO_BANK_DATA" def get_chart_data_from_csv(transactions: list) -> dict: """CSV mode: build chart data from user-uploaded transactions.""" end_date = date.today() start_date = end_date.replace(day=1) try: balances = _build_synthetic_balances_from_transactions(transactions) return _build_chart_data(balances, transactions, start_date, end_date) except Exception as e: print(f"CSV chart_data error: {e}", flush=True) return {"burndown": [], "income_total": 0, "expense_total": 0, "accounts": []} def get_recurring_from_csv(transactions: list) -> dict: """CSV mode: detect recurring patterns from user-uploaded transactions.""" end_date = date.today() start_date = end_date - timedelta(days=180) try: return _detect_recurring(transactions, start_date, end_date) except Exception as e: print(f"CSV recurring error: {e}", flush=True) return { 'recurring_expenses': [], 'recurring_income': [], 'projected_events': [], 'analysis_period': None, }