466 lines
19 KiB
Python
466 lines
19 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)
|
|
|
|
# Credit cards: 'available' is the remaining credit line, not what's owed.
|
|
# Use 'ledger' (amount owed, negative in Teller's convention) for credit cards.
|
|
is_credit = ta.pfm_account.account_type == 'credit_card'
|
|
balance_to_store = ledger if is_credit else available
|
|
|
|
ta.pfm_account.balance = balance_to_store
|
|
db.session.commit()
|
|
log.info('[teller] balance refreshed for %s: available=%.2f ledger=%.2f stored=%.2f',
|
|
ta.account_name, available, ledger, balance_to_store)
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'available': available,
|
|
'ledger': ledger,
|
|
'balance': balance_to_store,
|
|
'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)
|
|
ledger = float(bal_data.get('ledger') or bal_data.get('available') or 0)
|
|
is_credit = ta.pfm_account.account_type == 'credit_card'
|
|
ta.pfm_account.balance = ledger if is_credit else available
|
|
refreshed.append(ta.account_name)
|
|
log.info('[teller] balance refreshed: %s = %.2f', ta.account_name, ta.pfm_account.balance)
|
|
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
|