import json import hmac import hashlib import logging from datetime import date from flask import (Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app, session) from flask_login import login_required, current_user from app.extensions import db from app.models.teller_enrollment import TellerEnrollment, TellerAccount, TellerSyncPreview from app.models.account import Account from app.services.teller_service import ( get_accounts, get_balance, sync_preview, import_transactions, ACCOUNT_TYPE_MAP, ) teller_bp = Blueprint('teller', __name__, url_prefix='/teller') log = logging.getLogger(__name__) from app.extensions import csrf as _csrf # Webhook receives POSTs from Teller servers — no CSRF token _csrf_exempt_views = ['teller.webhook'] # ── Connect callback ────────────────────────────────────────────────────────── @teller_bp.route('/callback', methods=['POST']) @login_required def callback(): """ Teller Connect posts here after user successfully enrolls. Body: { enrollment: { id, accessToken }, selectedAccount: { ... } } We store the enrollment and discovered accounts, then redirect to mapping. """ data = request.get_json() if not data: return jsonify({'error': 'No data'}), 400 # Teller Connect onSuccess payload: # { accessToken, user: {id}, enrollment: {id, institution: {name}} } # accessToken is top-level, enrollment.id is nested access_token = data.get('accessToken', '') enrollment_data = data.get('enrollment', {}) enrollment_id = enrollment_data.get('id', '') # Also handle if client wraps it: { enrollment: { accessToken, id, ... } } if not access_token and isinstance(enrollment_data, dict): access_token = enrollment_data.get('accessToken', '') if not enrollment_id and isinstance(enrollment_data, dict): enrollment_id = enrollment_data.get('id', '') log.info(f'[teller] callback received: enrollment_id={enrollment_id!r} token_present={bool(access_token)}') if not enrollment_id or not access_token: log.error(f'[teller] missing data. Keys received: {list(data.keys())}') return jsonify({'error': 'Missing enrollment data'}), 400 # Upsert enrollment enrollment = TellerEnrollment.query.filter_by(enrollment_id=enrollment_id).first() if not enrollment: enrollment = TellerEnrollment( enrollment_id=enrollment_id, access_token=access_token, ) db.session.add(enrollment) # Fetch accounts from Teller try: teller_accounts = get_accounts(access_token) except Exception as e: log.error('[teller] get_accounts failed during callback: %s', e, exc_info=True) return jsonify({'error': f'Could not fetch accounts: {e}'}), 502 institution_name = '' for ta in teller_accounts: institution_name = ta.get('institution', {}).get('name', '') ta_id = ta['id'] existing = TellerAccount.query.filter_by(teller_account_id=ta_id).first() if not existing: subtype = ta.get('subtype', 'other').lower() db.session.add(TellerAccount( enrollment=enrollment, teller_account_id=ta_id, account_name=ta.get('name', ''), account_type=ta.get('type', ''), account_subtype=subtype, institution_name=institution_name, )) # institution.name from enrollment object (not from account loop) if not institution_name: institution_name = enrollment_data.get('institution', {}).get('name', '') enrollment.institution_name = institution_name or enrollment.institution_name enrollment.user_id = data.get('user', {}).get('id', '') or enrollment_data.get('user', {}).get('id', '') db.session.commit() return jsonify({'status': 'ok', 'redirect': url_for('teller.map_accounts', enrollment_id=enrollment_id)}) @teller_bp.route('/map/', methods=['GET', 'POST']) @login_required def map_accounts(enrollment_id): """ Let user map each Teller account to a PFM account (or create new). POST saves the mapping and redirects to sync preview. """ enrollment = TellerEnrollment.query.filter_by(enrollment_id=enrollment_id).first_or_404() teller_accounts = enrollment.accounts.filter_by(is_active=True).all() pfm_accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all() if request.method == 'POST': for ta in teller_accounts: key = f'pfm_account_{ta.id}' val = request.form.get(key, '') if val == 'new': # Auto-create a new PFM account subtype = ta.account_subtype or 'checking' pfm_type = ACCOUNT_TYPE_MAP.get(subtype, 'other') new_acct = Account( name=f'{ta.institution_name} — {ta.account_name}', account_type=pfm_type, color='#4F81C7', icon='bi-bank', balance=0, ) db.session.add(new_acct) db.session.flush() ta.pfm_account_id = new_acct.id elif val.isdigit(): acct_id = int(val) # Verify the account actually exists and belongs to this app if Account.query.filter_by(id=acct_id, is_active=True).first(): ta.pfm_account_id = acct_id # val == '' means skip this account db.session.commit() # Redirect to sync all mapped accounts mapped = [ta for ta in teller_accounts if ta.pfm_account_id] if not mapped: flash('No accounts mapped. Select at least one account to sync.', 'warning') return redirect(url_for('teller.map_accounts', enrollment_id=enrollment_id)) flash(f'{len(mapped)} account(s) mapped. Ready to sync.', 'success') return redirect(url_for('teller.index')) return render_template('teller/map_accounts.html', enrollment=enrollment, teller_accounts=teller_accounts, pfm_accounts=pfm_accounts) # ── Index — enrolled accounts overview ─────────────────────────────────────── @teller_bp.route('/') @login_required def index(): enrollments = TellerEnrollment.query.filter_by(is_active=True).all() return render_template('teller/index.html', enrollments=enrollments, teller_app_id=current_app.config.get('TELLER_APP_ID', ''), teller_env=current_app.config.get('TELLER_ENV', 'development'), teller_cert_path=current_app.config.get('TELLER_CERT_PATH', ''), teller_key_path=current_app.config.get('TELLER_KEY_PATH', '')) # ── Sync: preview then confirm ──────────────────────────────────────────────── @teller_bp.route('/sync/', methods=['GET']) @login_required def sync_preview_view(teller_account_id): """Fetch transactions from Teller and show preview before importing.""" ta = db.get_or_404(TellerAccount, teller_account_id) if not ta.pfm_account_id: flash('This account is not mapped to a PFM account. Please map it first.', 'warning') return redirect(url_for('teller.map_accounts', enrollment_id=ta.enrollment.enrollment_id)) try: preview = sync_preview(ta) except Exception as e: log.error('[teller] sync_preview failed for teller_account_id=%s: %s', teller_account_id, e, exc_info=True) flash(f'Sync failed: {e}', 'danger') return redirect(url_for('teller.index')) # Store preview in DB (not session cookie — cookie limit is 4 KB; # large accounts with 100+ transactions silently exceeded it). preview_data = [ { 'teller_id': p['teller_id'], 'date': p['date'].isoformat(), 'transaction_type': p['transaction_type'], 'amount': float(p['amount']), 'description': p['description'], 'account_id': p['account_id'], 'category_id': p['category_id'], 'notes': p['notes'], } for p in preview ] # Upsert: one preview row per teller account (overwrite any stale preview) existing_sp = TellerSyncPreview.query.filter_by(teller_account_id=teller_account_id).first() if existing_sp: existing_sp.data_json = json.dumps(preview_data) existing_sp.created_at = __import__('datetime').datetime.utcnow() sp = existing_sp else: sp = TellerSyncPreview(teller_account_id=teller_account_id, data_json=json.dumps(preview_data)) db.session.add(sp) db.session.commit() # Only the tiny row ID goes into the session cookie session['teller_preview_id'] = sp.id session['teller_account_id'] = teller_account_id from app.models.category import Category cats = Category.query.filter_by(is_active=True).order_by(Category.name).all() cat_map = {c.id: c.name for c in cats} return render_template('teller/preview.html', ta=ta, preview=preview, count=len(preview), cat_map=cat_map, categories=cats) @teller_bp.route('/sync/confirm', methods=['POST']) @login_required def sync_confirm(): """Import selected previewed transactions with optional type overrides.""" preview_id = session.pop('teller_preview_id', None) ta_id = session.pop('teller_account_id', None) if not preview_id or not ta_id: flash('No pending import. Please sync again.', 'warning') return redirect(url_for('teller.index')) sp = db.session.get(TellerSyncPreview, preview_id) if not sp: flash('Preview expired or already imported. Please sync again.', 'warning') return redirect(url_for('teller.index')) raw = json.loads(sp.data_json) db.session.delete(sp) db.session.commit() ta = db.get_or_404(TellerAccount, ta_id) selected_ids = set(request.form.getlist('selected')) from datetime import date as date_cls parsed = [] for r in raw: if selected_ids and r['teller_id'] not in selected_ids: continue r['date'] = date_cls.fromisoformat(r['date']) override = request.form.get(f'type_{r["teller_id"]}') if override in ('income', 'expense'): r['transaction_type'] = override cat_override = request.form.get(f'category_{r["teller_id"]}', '') if cat_override.isdigit(): r['category_id'] = int(cat_override) parsed.append(r) if not parsed: flash('No transactions selected. Nothing was imported.', 'warning') return redirect(url_for('teller.index')) imported, skipped = import_transactions(parsed, ta) flash(f'Imported {imported} transaction(s) from {ta.account_name}. ' f'Skipped {skipped} duplicate(s).', 'success') # Advance the sync queue if this came from a "Sync All" run from flask import session as flask_session queue = flask_session.pop('teller_sync_queue', []) if queue: next_id = queue[0] flask_session['teller_sync_queue'] = queue[1:] flash(f'{len(queue)} account(s) remaining in sync queue.', 'info') return redirect(url_for('teller.sync_preview_view', teller_account_id=next_id)) return redirect(url_for('transactions.index')) @teller_bp.route('/sync/all', methods=['POST']) @login_required def sync_all(): """ Queue all mapped accounts for sequential sync. Stores the full list in session, then redirects to the first account's preview. After each confirm the queue advances automatically. """ from flask import session as flask_session enrollments = TellerEnrollment.query.filter_by(is_active=True).all() mapped = [] for e in enrollments: for ta in e.accounts.filter_by(is_active=True).all(): if ta.pfm_account_id: mapped.append(ta) if not mapped: flash('No accounts mapped for sync.', 'warning') return redirect(url_for('teller.index')) # Store the full queue; first item will be previewed immediately flask_session['teller_sync_queue'] = [ta.id for ta in mapped[1:]] return redirect(url_for('teller.sync_preview_view', teller_account_id=mapped[0].id)) # ── Full resync (reset last_sync_date so next sync fetches full history) ───── @teller_bp.route('/resync/', methods=['POST']) @login_required def full_resync(teller_account_id): ta = db.get_or_404(TellerAccount, teller_account_id) ta.last_sync_date = None ta.last_teller_txn_id = None db.session.commit() flash( f'{ta.account_name}: reset to full resync. ' f'Next sync will fetch the last 90 days — duplicates will be skipped automatically.', 'info' ) next_url = request.form.get('next', '') if next_url and next_url.startswith('/'): return redirect(next_url) return redirect(url_for('teller.index')) # ── Balance refresh ─────────────────────────────────────────────────────────── @teller_bp.route('/balance/', methods=['POST']) @login_required def refresh_balance(teller_account_id): """Fetch live balance from Teller and update the linked PFM account.""" ta = db.get_or_404(TellerAccount, teller_account_id) if not ta.pfm_account_id: return jsonify({'error': 'Account not mapped'}), 400 try: bal_data = get_balance(ta.enrollment.access_token, ta.teller_account_id) available = float(bal_data.get('available') or bal_data.get('ledger') or 0) ledger = float(bal_data.get('ledger') or bal_data.get('available') or 0) # Credit cards: 'available' is the remaining credit line, not what's owed. # Use 'ledger' for credit cards and force it negative (our debt convention). # Teller may return ledger as positive or negative depending on the bank. is_credit = ta.pfm_account.account_type == 'credit_card' balance_to_store = -abs(ledger) if is_credit else available ta.pfm_account.balance = balance_to_store db.session.commit() log.info('[teller] balance refreshed for %s: available=%.2f ledger=%.2f stored=%.2f', ta.account_name, available, ledger, balance_to_store) return jsonify({ 'status': 'ok', 'available': available, 'ledger': ledger, 'balance': balance_to_store, 'account': ta.account_name, }) except Exception as e: log.error('[teller] balance refresh failed for teller_account_id=%s: %s', teller_account_id, e, exc_info=True) return jsonify({'error': str(e)}), 502 @teller_bp.route('/balance/all', methods=['POST']) @login_required def refresh_all_balances(): """Refresh live balances for every mapped Teller account across all enrollments.""" enrollments = TellerEnrollment.query.filter_by(is_active=True).all() refreshed, failed = [], [] for enrollment in enrollments: for ta in enrollment.accounts.filter_by(is_active=True).all(): if not ta.pfm_account_id: continue try: bal_data = get_balance(enrollment.access_token, ta.teller_account_id) available = float(bal_data.get('available') or bal_data.get('ledger') or 0) ledger = float(bal_data.get('ledger') or bal_data.get('available') or 0) is_credit = ta.pfm_account.account_type == 'credit_card' ta.pfm_account.balance = -abs(ledger) if is_credit else available refreshed.append(ta.account_name) log.info('[teller] balance refreshed: %s = %.2f', ta.account_name, ta.pfm_account.balance) except Exception as exc: failed.append(ta.account_name) log.error('[teller] balance refresh failed for %s: %s', ta.account_name, exc, exc_info=True) if refreshed or failed: db.session.commit() if refreshed: flash(f'Balances updated for: {", ".join(refreshed)}.', 'success') if failed: flash(f'Failed to refresh: {", ".join(failed)}. Check System Logs for details.', 'danger') if not refreshed and not failed: flash('No mapped accounts found to refresh.', 'warning') return redirect(url_for('teller.index')) # ── Disconnect ──────────────────────────────────────────────────────────────── @teller_bp.route('/disconnect/', methods=['POST']) @login_required def disconnect(enrollment_db_id): enrollment = db.get_or_404(TellerEnrollment, enrollment_db_id) enrollment.is_active = False for ta in enrollment.accounts: ta.is_active = False db.session.commit() flash(f'Disconnected from {enrollment.institution_name}.', 'info') return redirect(url_for('teller.index')) # ── Webhook: transactions.processed ────────────────────────────────────────── @teller_bp.route('/webhook', methods=['GET', 'POST']) @_csrf.exempt def webhook(): """ Teller fires this when new transactions are available. Verifies signature then marks account as needing sync. Does NOT auto-import — user must confirm via the UI. """ # Verify Teller webhook signature # Header format: Teller-Signature: t=,v1=,v1= # Signed message: . signing_secret = current_app.config.get('TELLER_WEBHOOK_SECRET', '') if not signing_secret: log.error('[teller] TELLER_WEBHOOK_SECRET not configured — rejecting webhook') return jsonify({'error': 'Webhook verification not configured'}), 403 sig_header = request.headers.get('Teller-Signature', '') body = request.get_data() if not sig_header: log.warning('[teller] missing Teller-Signature header') return jsonify({'error': 'Missing signature'}), 401 # Parse header: t=,v1=,v1=... parts = dict( (p.split('=', 1) if '=' in p else (p, '')) for p in sig_header.split(',') ) timestamp = parts.get('t', '') # Collect all v1 signatures (may be multiple during key rotation) signatures = [v for k, v in [p.split('=', 1) for p in sig_header.split(',') if p.startswith('v1=')] ] if not timestamp or not signatures: log.warning('[teller] malformed Teller-Signature header') return jsonify({'error': 'Invalid signature'}), 401 # Reject replays older than 5 minutes import time try: if abs(time.time() - int(timestamp)) > 300: log.warning('[teller] webhook replay attack detected') return jsonify({'error': 'Timestamp too old'}), 401 except ValueError: pass # signed_message = timestamp + "." + raw_body signed_message = f'{timestamp}.'.encode() + body expected = hmac.new( signing_secret.encode(), signed_message, hashlib.sha256 ).hexdigest() if not any(hmac.compare_digest(expected, sig) for sig in signatures): log.warning('[teller] webhook signature mismatch') return jsonify({'error': 'Invalid signature'}), 401 # Teller sends GET to verify the endpoint is reachable if request.method == 'GET': return jsonify({'status': 'ok', 'service': 'pfm-teller-webhook'}), 200 payload = request.get_json() if not payload: return jsonify({'error': 'No payload'}), 400 event_type = payload.get('type', '') log.info(f'[teller] webhook received: {event_type}') if event_type == 'transactions.processed': enrollment_id = payload.get('enrollment_id', '') enrollment = TellerEnrollment.query.filter_by(enrollment_id=enrollment_id).first() if enrollment: # Just log — user syncs manually via UI preview flow log.info(f'[teller] new transactions available for enrollment {enrollment_id}') return jsonify({'status': 'received'}), 200