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 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 session for confirm step session['teller_preview'] = [ { 'teller_id': p['teller_id'], 'date': p['date'].isoformat(), 'transaction_type': p['transaction_type'], 'amount': p['amount'], 'description': p['description'], 'account_id': p['account_id'], 'category_id': p['category_id'], 'notes': p['notes'], } for p in preview ] session['teller_account_id'] = teller_account_id return render_template('teller/preview.html', ta=ta, preview=preview, count=len(preview)) @teller_bp.route('/sync/confirm', methods=['POST']) @login_required def sync_confirm(): """Import the previewed transactions.""" raw = session.pop('teller_preview', []) ta_id = session.pop('teller_account_id', None) if not raw or not ta_id: flash('No pending import. Please sync again.', 'warning') return redirect(url_for('teller.index')) ta = db.get_or_404(TellerAccount, ta_id) # Reconstruct parsed list with date objects from datetime import date as date_cls parsed = [] for r in raw: r['date'] = date_cls.fromisoformat(r['date']) parsed.append(r) 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)) # ── 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) ta.pfm_account.balance = available db.session.commit() return jsonify({'balance': available, 'status': 'ok'}) 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 # ── 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 signing_secret: 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