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 enrollment_data = data.get('enrollment', {}) enrollment_id = enrollment_data.get('id', '') access_token = enrollment_data.get('accessToken', '') if not enrollment_id or not access_token: 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(f'[teller] get_accounts failed: {e}') 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, )) enrollment.institution_name = institution_name enrollment.user_id = 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(): ta.pfm_account_id = int(val) # 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) # ── 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: 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). Skipped {skipped} duplicate(s).', 'success') return redirect(url_for('transactions.index')) @teller_bp.route('/sync/all', methods=['POST']) @login_required def sync_all(): """Sync all mapped accounts — redirects to first account's preview.""" 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')) # For simplicity, sync first account; user can chain through others 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(f'[teller] balance refresh failed: {e}') 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