Files
Personal-Finance-Management/app/routes/teller.py
T
2026-05-31 23:17:28 -04:00

358 lines
14 KiB
Python

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(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,
))
# 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/<enrollment_id>', 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,
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/<int:teller_account_id>', 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/<int:teller_account_id>', 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/<int:enrollment_db_id>', 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=<timestamp>,v1=<sig1>,v1=<sig2>
# Signed message: <timestamp>.<raw_body>
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=<timestamp>,v1=<sig>,v1=<sig2>...
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