""" Teller Service — connects to Teller API using mTLS + HTTP Basic Auth. Authentication: - mTLS: client cert + key (downloaded from Teller Dashboard) - HTTP Basic Auth: access_token as username, empty password Endpoints used: GET /accounts → list enrolled accounts GET /accounts/:id/balances → account balance GET /accounts/:id/transactions → transaction list (with date range) Environment: development (real banks, reviewed by Teller) """ import logging import os import requests from datetime import date, timedelta from flask import current_app log = logging.getLogger(__name__) TELLER_BASE = 'https://api.teller.io' TELLER_VERSION = '2020-10-12' # Teller category → PFM category name mapping CATEGORY_MAP = { 'accommodation': 'Housing', 'advertising': 'Other', 'bar': 'Food & Dining', 'charity': 'Gifts', 'clothing': 'Shopping', 'dining': 'Food & Dining', 'education': 'Education', 'electronics': 'Shopping', 'entertainment': 'Entertainment', 'fuel': 'Transport', 'general': 'Other', 'groceries': 'Food & Dining', 'health': 'Health', 'home': 'Housing', 'income': 'Other Income', 'insurance': 'Insurance', 'investment': 'Investment', 'loan': 'Other', 'office': 'Other', 'phone': 'Utilities', 'service': 'Other', 'shopping': 'Shopping', 'software': 'Subscriptions', 'sport': 'Health', 'tax': 'Other', 'transport': 'Transport', 'transportation': 'Transport', 'utilities': 'Utilities', } # Teller account subtype → PFM account type ACCOUNT_TYPE_MAP = { 'checking': 'checking', 'savings': 'savings', 'credit_card': 'credit_card', 'money_market':'savings', 'cd': 'savings', 'brokerage': 'investment', 'ira': 'investment', '401k': 'investment', 'other': 'other', } def _session(access_token): """ Build a requests.Session with mTLS client certificate and HTTP Basic Auth. Cert/key paths come from app config (TELLER_CERT_PATH / TELLER_KEY_PATH). """ cert_path = current_app.config.get('TELLER_CERT_PATH', '') key_path = current_app.config.get('TELLER_KEY_PATH', '') log.debug(f'[teller] _session: cert={cert_path!r} key={key_path!r}') if not cert_path or not key_path: raise ValueError('TELLER_CERT_PATH and TELLER_KEY_PATH must be set in .env') if not os.path.exists(cert_path): raise FileNotFoundError(f'Teller cert not found: {cert_path}') if not os.path.exists(key_path): raise FileNotFoundError(f'Teller key not found: {key_path}') session = requests.Session() session.cert = (cert_path, key_path) session.auth = (access_token, '') session.headers.update({ 'Teller-Version': TELLER_VERSION, 'Accept': 'application/json', }) return session def _raise_for_status_with_log(resp, context=''): """Call raise_for_status() but log the response body first on error.""" if not resp.ok: log.error( '[teller] API error%s — status=%s url=%s body=%r', f' ({context})' if context else '', resp.status_code, resp.url, resp.text[:2000], ) resp.raise_for_status() def get_accounts(access_token): """ Fetch all accounts for an enrollment. Returns list of account dicts or raises on error. """ log.info('[teller] get_accounts: requesting %s/accounts', TELLER_BASE) session = _session(access_token) try: resp = session.get(f'{TELLER_BASE}/accounts', timeout=15) except requests.exceptions.RequestException as e: log.error('[teller] get_accounts: request failed: %s', e, exc_info=True) raise _raise_for_status_with_log(resp, 'get_accounts') data = resp.json() log.info('[teller] get_accounts: returned %d account(s)', len(data)) return data def get_balance(access_token, account_id): """Fetch live balance for a single account.""" log.info('[teller] get_balance: account=%s', account_id) session = _session(access_token) try: resp = session.get(f'{TELLER_BASE}/accounts/{account_id}/balances', timeout=15) except requests.exceptions.RequestException as e: log.error('[teller] get_balance: request failed: %s', e, exc_info=True) raise _raise_for_status_with_log(resp, f'get_balance account={account_id}') return resp.json() def get_transactions(access_token, account_id, start_date=None, end_date=None, from_id=None, count=None): """ Fetch transactions for an account. Uses date range for initial sync, from_id for incremental. Returns list of transaction dicts. """ session = _session(access_token) params = {} if start_date: params['start_date'] = start_date.isoformat() if hasattr(start_date, 'isoformat') else start_date if end_date: params['end_date'] = end_date.isoformat() if hasattr(end_date, 'isoformat') else end_date if from_id: params['from_id'] = from_id if count: params['count'] = count log.info('[teller] get_transactions: account=%s params=%s', account_id, params) try: resp = session.get( f'{TELLER_BASE}/accounts/{account_id}/transactions', params=params, timeout=30, ) except requests.exceptions.RequestException as e: log.error('[teller] get_transactions: request failed: %s', e, exc_info=True) raise _raise_for_status_with_log(resp, f'get_transactions account={account_id}') data = resp.json() log.info('[teller] get_transactions: returned %d transaction(s)', len(data)) return data def parse_transaction(teller_txn, pfm_account_id, category_id_map): """ Convert a Teller transaction dict to a PFM transaction dict ready for import. Returns dict with keys matching Transaction model fields. Teller amounts: - Positive = money leaving the account (expense / debit) - Negative = money entering the account (income / credit) """ amount_raw = float(teller_txn.get('amount', 0)) # Teller: positive = outflow (expense), negative = inflow (income) if amount_raw > 0: txn_type = 'expense' amount = amount_raw else: txn_type = 'income' amount = abs(amount_raw) description = teller_txn.get('description', '').strip() or 'Teller transaction' # Use enriched counterparty name if available details = teller_txn.get('details', {}) or {} counterparty = (details.get('counterparty') or {}).get('name', '') if counterparty and counterparty.upper() != description.upper(): description = counterparty # Map Teller category to PFM category teller_cat = (details.get('category') or '').lower() pfm_cat_name = CATEGORY_MAP.get(teller_cat, 'Other') category_id = category_id_map.get(pfm_cat_name) return { 'teller_id': teller_txn['id'], 'date': date.fromisoformat(teller_txn['date']), 'transaction_type': txn_type, 'amount': amount, 'description': description, 'account_id': pfm_account_id, 'category_id': category_id, 'notes': f'Teller: {teller_txn.get("type", "")}', 'status': teller_txn.get('status', 'posted'), } def build_category_map(): """Build {pfm_category_name: category_id} from DB.""" from app.models.category import Category cats = Category.query.filter_by(is_active=True).all() return {c.name: c.id for c in cats} def sync_preview(teller_account, days_back=90): """ Fetch transactions for a TellerAccount and return a preview list. Does NOT write to DB — just returns parsed dicts for display. For incremental sync: uses last_sync_date - 7 days as start_date. For first sync: goes back `days_back` days. """ from app.models.teller_enrollment import TellerEnrollment enrollment = teller_account.enrollment today = date.today() if teller_account.last_sync_date: # Incremental: overlap 7 days to catch pending→posted changes start = teller_account.last_sync_date - timedelta(days=7) else: # First sync start = today - timedelta(days=days_back) try: raw_txns = get_transactions( enrollment.access_token, teller_account.teller_account_id, start_date=start, end_date=today, ) except requests.exceptions.HTTPError as e: body = e.response.text[:2000] if e.response is not None else '(no response)' log.error( '[teller] fetch failed for %s: %s — body=%r', teller_account.teller_account_id, e, body, exc_info=True, ) raise cat_map = build_category_map() parsed = [] for txn in raw_txns: p = parse_transaction(txn, teller_account.pfm_account_id, cat_map) parsed.append(p) return parsed def import_transactions(parsed_txns, teller_account): """ Import a list of already-parsed transaction dicts into PFM. Skips duplicates based on teller_id stored in notes field. Returns (imported_count, skipped_count). """ from app.extensions import db from app.models.transaction import Transaction from app.services.account_service import calc_balance imported = 0 skipped = 0 affected_accounts = set() for p in parsed_txns: # Duplicate check: match on teller_id in notes OR date+amount+description teller_id = p['teller_id'] existing = Transaction.query.filter( Transaction.notes.like(f'%{teller_id}%') ).first() if existing: skipped += 1 continue txn = Transaction( account_id=p['account_id'], category_id=p.get('category_id'), transaction_type=p['transaction_type'], amount=p['amount'], description=p['description'], date=p['date'], notes=f"Teller:{teller_id}", ) db.session.add(txn) if p['account_id']: affected_accounts.add(p['account_id']) imported += 1 db.session.commit() # Update sync metadata from datetime import datetime teller_account.last_sync_date = date.today() if parsed_txns: teller_account.last_teller_txn_id = parsed_txns[0]['teller_id'] from app.extensions import db as _db _db.session.commit() for account_id in affected_accounts: calc_balance(account_id) return imported, skipped