359 lines
15 KiB
Python
359 lines
15 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():
|
|
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:
|
|
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). 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('[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/<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
|