05/31 Teller.io connection
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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/<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)
|
||||
|
||||
|
||||
# ── 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=['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
|
||||
signing_secret = current_app.config.get('TELLER_WEBHOOK_SECRET', '')
|
||||
if signing_secret:
|
||||
sig_header = request.headers.get('Teller-Signature', '')
|
||||
body = request.get_data()
|
||||
expected = hmac.new(signing_secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(f'sha256={expected}', sig_header):
|
||||
log.warning('[teller] webhook signature mismatch')
|
||||
return jsonify({'error': 'Invalid signature'}), 401
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user