Files
Personal-Finance-Management/app/routes/teller.py
T
2026-06-02 16:34:16 -04:00

457 lines
18 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('[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/<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():
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/<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:
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
from app.models.category import Category
cat_map = {c.id: c.name for c in Category.query.filter_by(is_active=True).all()}
return render_template('teller/preview.html',
ta=ta,
preview=preview,
count=len(preview),
cat_map=cat_map)
@teller_bp.route('/sync/confirm', methods=['POST'])
@login_required
def sync_confirm():
"""Import selected previewed transactions with optional type overrides."""
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)
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
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/<int:teller_account_id>', 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'
)
return redirect(url_for('teller.index'))
# ── 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)
ledger = float(bal_data.get('ledger') or bal_data.get('available') or 0)
ta.pfm_account.balance = available
db.session.commit()
log.info('[teller] balance refreshed for %s: available=%.2f ledger=%.2f',
ta.account_name, available, ledger)
return jsonify({
'status': 'ok',
'available': available,
'ledger': ledger,
'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)
ta.pfm_account.balance = available
refreshed.append(ta.account_name)
log.info('[teller] balance refreshed: %s = %.2f', ta.account_name, available)
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/<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 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=<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