05/31 Teller.io connection

This commit is contained in:
2026-05-31 22:40:02 -04:00
parent 67ed91d94c
commit 1d7ae3caf0
12 changed files with 1055 additions and 0 deletions
+3
View File
@@ -29,6 +29,7 @@ def create_app(config_name=None):
from app.routes.ai import ai_bp
from app.routes.reports import reports_bp
from app.routes.settings import settings_bp
from app.routes.teller import teller_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
@@ -41,6 +42,7 @@ def create_app(config_name=None):
app.register_blueprint(ai_bp)
app.register_blueprint(reports_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(teller_bp)
with app.app_context():
from app.models import (
@@ -49,6 +51,7 @@ def create_app(config_name=None):
Investment, InvestmentTransaction, NetWorthSnapshot,
AiInsight, FxRate
)
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
app.jinja_env.globals['format_currency'] = format_currency
app.jinja_env.globals['format_percent'] = format_percent
+7
View File
@@ -23,6 +23,13 @@ class Config:
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
# Teller
TELLER_APP_ID = os.environ.get('TELLER_APP_ID', '')
TELLER_ENV = os.environ.get('TELLER_ENV', 'development')
TELLER_CERT_PATH = os.environ.get('TELLER_CERT_PATH', '/home/pfm/teller/certificate.pem')
TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem')
TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '')
class DevelopmentConfig(Config):
DEBUG = True
+2
View File
@@ -10,3 +10,5 @@ from app.models.investment import Investment, InvestmentTransaction
from app.models.net_worth_snapshot import NetWorthSnapshot
from app.models.ai_insight import AiInsight
from app.models.fx_rate import FxRate
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
+53
View File
@@ -0,0 +1,53 @@
from app.extensions import db
from datetime import datetime
class TellerEnrollment(db.Model):
"""
Represents a Teller enrollment — one per connected bank institution.
An enrollment contains one or more accounts.
"""
__tablename__ = 'teller_enrollments'
id = db.Column(db.Integer, primary_key=True)
enrollment_id = db.Column(db.String(64), unique=True, nullable=False, index=True)
access_token = db.Column(db.String(128), nullable=False)
institution_name = db.Column(db.String(100), nullable=True)
user_id = db.Column(db.String(64), nullable=True) # Teller user ID
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_synced_at = db.Column(db.DateTime, nullable=True)
accounts = db.relationship('TellerAccount', back_populates='enrollment',
cascade='all, delete-orphan', lazy='dynamic')
def __repr__(self):
return f'<TellerEnrollment {self.institution_name} ({self.enrollment_id})>'
class TellerAccount(db.Model):
"""
Maps a Teller account to a PFM account.
Tracks the last synced transaction ID for incremental syncs.
"""
__tablename__ = 'teller_accounts'
id = db.Column(db.Integer, primary_key=True)
enrollment_id = db.Column(db.Integer, db.ForeignKey('teller_enrollments.id'), nullable=False)
teller_account_id = db.Column(db.String(64), unique=True, nullable=False, index=True)
pfm_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True)
account_name = db.Column(db.String(100), nullable=True)
account_type = db.Column(db.String(50), nullable=True) # depository / credit
account_subtype = db.Column(db.String(50), nullable=True) # checking / savings / credit_card
institution_name = db.Column(db.String(100), nullable=True)
last_sync_date = db.Column(db.Date, nullable=True) # date of last successful sync
last_teller_txn_id = db.Column(db.String(64), nullable=True) # for from_id pagination
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
enrollment = db.relationship('TellerEnrollment', back_populates='accounts')
pfm_account = db.relationship('Account')
def __repr__(self):
return f'<TellerAccount {self.account_name} → PFM #{self.pfm_account_id}>'
+297
View File
@@ -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
+284
View File
@@ -0,0 +1,284 @@
"""
Teller Service — connects to Teller API using mTLS + HTTP Basic Auth.
Authentication:
- mTLS: client cert + key (downloaded from Teller Dashboard)
- HTTP Basic Auth: access_token as username, empty password
Endpoints used:
GET /accounts → list enrolled accounts
GET /accounts/:id/balances → account balance
GET /accounts/:id/transactions → transaction list (with date range)
Environment: development (real banks, reviewed by Teller)
"""
import logging
import os
import requests
from datetime import date, timedelta
from flask import current_app
log = logging.getLogger(__name__)
TELLER_BASE = 'https://api.teller.io'
TELLER_VERSION = '2020-10-12'
# Teller category → PFM category name mapping
CATEGORY_MAP = {
'accommodation': 'Housing',
'advertising': 'Other',
'bar': 'Food & Dining',
'charity': 'Gifts',
'clothing': 'Shopping',
'dining': 'Food & Dining',
'education': 'Education',
'electronics': 'Shopping',
'entertainment': 'Entertainment',
'fuel': 'Transport',
'general': 'Other',
'groceries': 'Food & Dining',
'health': 'Health',
'home': 'Housing',
'income': 'Other Income',
'insurance': 'Insurance',
'investment': 'Investment',
'loan': 'Other',
'office': 'Other',
'phone': 'Utilities',
'service': 'Other',
'shopping': 'Shopping',
'software': 'Subscriptions',
'sport': 'Health',
'tax': 'Other',
'transport': 'Transport',
'transportation': 'Transport',
'utilities': 'Utilities',
}
# Teller account subtype → PFM account type
ACCOUNT_TYPE_MAP = {
'checking': 'checking',
'savings': 'savings',
'credit_card': 'credit_card',
'money_market':'savings',
'cd': 'savings',
'brokerage': 'investment',
'ira': 'investment',
'401k': 'investment',
'other': 'other',
}
def _session(access_token):
"""
Build a requests.Session with mTLS client certificate and HTTP Basic Auth.
Cert/key paths come from app config (TELLER_CERT_PATH / TELLER_KEY_PATH).
"""
cert_path = current_app.config.get('TELLER_CERT_PATH', '')
key_path = current_app.config.get('TELLER_KEY_PATH', '')
if not cert_path or not key_path:
raise ValueError('TELLER_CERT_PATH and TELLER_KEY_PATH must be set in .env')
if not os.path.exists(cert_path):
raise FileNotFoundError(f'Teller cert not found: {cert_path}')
if not os.path.exists(key_path):
raise FileNotFoundError(f'Teller key not found: {key_path}')
session = requests.Session()
session.cert = (cert_path, key_path)
session.auth = (access_token, '')
session.headers.update({
'Teller-Version': TELLER_VERSION,
'Accept': 'application/json',
})
return session
def get_accounts(access_token):
"""
Fetch all accounts for an enrollment.
Returns list of account dicts or raises on error.
"""
session = _session(access_token)
resp = session.get(f'{TELLER_BASE}/accounts', timeout=15)
resp.raise_for_status()
return resp.json()
def get_balance(access_token, account_id):
"""Fetch live balance for a single account."""
session = _session(access_token)
resp = session.get(f'{TELLER_BASE}/accounts/{account_id}/balances', timeout=15)
resp.raise_for_status()
return resp.json()
def get_transactions(access_token, account_id, start_date=None, end_date=None,
from_id=None, count=None):
"""
Fetch transactions for an account.
Uses date range for initial sync, from_id for incremental.
Returns list of transaction dicts.
"""
session = _session(access_token)
params = {}
if start_date:
params['start_date'] = start_date.isoformat() if hasattr(start_date, 'isoformat') else start_date
if end_date:
params['end_date'] = end_date.isoformat() if hasattr(end_date, 'isoformat') else end_date
if from_id:
params['from_id'] = from_id
if count:
params['count'] = count
resp = session.get(
f'{TELLER_BASE}/accounts/{account_id}/transactions',
params=params,
timeout=30,
)
resp.raise_for_status()
return resp.json()
def parse_transaction(teller_txn, pfm_account_id, category_id_map):
"""
Convert a Teller transaction dict to a PFM transaction dict ready for import.
Returns dict with keys matching Transaction model fields.
Teller amounts:
- Positive = money leaving the account (expense / debit)
- Negative = money entering the account (income / credit)
"""
amount_raw = float(teller_txn.get('amount', 0))
# Teller: positive = outflow (expense), negative = inflow (income)
if amount_raw > 0:
txn_type = 'expense'
amount = amount_raw
else:
txn_type = 'income'
amount = abs(amount_raw)
description = teller_txn.get('description', '').strip() or 'Teller transaction'
# Use enriched counterparty name if available
details = teller_txn.get('details', {}) or {}
counterparty = (details.get('counterparty') or {}).get('name', '')
if counterparty and counterparty.upper() != description.upper():
description = counterparty
# Map Teller category to PFM category
teller_cat = (details.get('category') or '').lower()
pfm_cat_name = CATEGORY_MAP.get(teller_cat, 'Other')
category_id = category_id_map.get(pfm_cat_name)
return {
'teller_id': teller_txn['id'],
'date': date.fromisoformat(teller_txn['date']),
'transaction_type': txn_type,
'amount': amount,
'description': description,
'account_id': pfm_account_id,
'category_id': category_id,
'notes': f'Teller: {teller_txn.get("type", "")}',
'status': teller_txn.get('status', 'posted'),
}
def build_category_map():
"""Build {pfm_category_name: category_id} from DB."""
from app.models.category import Category
cats = Category.query.filter_by(is_active=True).all()
return {c.name: c.id for c in cats}
def sync_preview(teller_account, days_back=90):
"""
Fetch transactions for a TellerAccount and return a preview list.
Does NOT write to DB — just returns parsed dicts for display.
For incremental sync: uses last_sync_date - 7 days as start_date.
For first sync: goes back `days_back` days.
"""
from app.models.teller_enrollment import TellerEnrollment
enrollment = teller_account.enrollment
today = date.today()
if teller_account.last_sync_date:
# Incremental: overlap 7 days to catch pending→posted changes
start = teller_account.last_sync_date - timedelta(days=7)
else:
# First sync
start = today - timedelta(days=days_back)
try:
raw_txns = get_transactions(
enrollment.access_token,
teller_account.teller_account_id,
start_date=start,
end_date=today,
)
except requests.exceptions.HTTPError as e:
log.error(f'[teller] fetch failed for {teller_account.teller_account_id}: {e}')
raise
cat_map = build_category_map()
parsed = []
for txn in raw_txns:
p = parse_transaction(txn, teller_account.pfm_account_id, cat_map)
parsed.append(p)
return parsed
def import_transactions(parsed_txns, teller_account):
"""
Import a list of already-parsed transaction dicts into PFM.
Skips duplicates based on teller_id stored in notes field.
Returns (imported_count, skipped_count).
"""
from app.extensions import db
from app.models.transaction import Transaction
from app.services.account_service import calc_balance
imported = 0
skipped = 0
affected_accounts = set()
for p in parsed_txns:
# Duplicate check: match on teller_id in notes OR date+amount+description
teller_id = p['teller_id']
existing = Transaction.query.filter(
Transaction.notes.like(f'%{teller_id}%')
).first()
if existing:
skipped += 1
continue
txn = Transaction(
account_id=p['account_id'],
category_id=p.get('category_id'),
transaction_type=p['transaction_type'],
amount=p['amount'],
description=p['description'],
date=p['date'],
notes=f"Teller:{teller_id}",
)
db.session.add(txn)
if p['account_id']:
affected_accounts.add(p['account_id'])
imported += 1
db.session.commit()
# Update sync metadata
from datetime import datetime
teller_account.last_sync_date = date.today()
if parsed_txns:
teller_account.last_teller_txn_id = parsed_txns[0]['teller_id']
from app.extensions import db as _db
_db.session.commit()
for account_id in affected_accounts:
calc_balance(account_id)
return imported, skipped
+9
View File
@@ -6,6 +6,15 @@
<div class="row g-3">
<!-- Nav cards -->
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('teller.index') }}" class="text-decoration-none">
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#10b981'" onmouseout="this.style.borderColor='var(--border)'">
<i class="bi bi-bank2" style="font-size:2rem;color:#10b981;"></i>
<div style="font-size:14px;font-weight:600;margin-top:10px;">Bank Connections</div>
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Connect US bank accounts via Teller</div>
</div>
</a>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('settings.profile') }}" class="text-decoration-none">
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#3b82f6'" onmouseout="this.style.borderColor='var(--border)'">
<i class="bi bi-person-circle" style="font-size:2rem;color:#3b82f6;"></i>
+192
View File
@@ -0,0 +1,192 @@
{% extends "base.html" %}
{% block title %}Bank Connections{% endblock %}
{% block page_title %}Bank Connections{% endblock %}
{% block topbar_actions %}
<button id="tellerConnectBtn" class="btn btn-sm btn-primary" style="font-size:12px;">
<i class="bi bi-bank me-1"></i>Connect a Bank
</button>
{% endblock %}
{% block content %}
{% if enrollments %}
<div class="row g-3 mb-4">
{% for enrollment in enrollments %}
{% set accounts = enrollment.accounts.filter_by(is_active=True).all() %}
<div class="col-12 col-lg-8">
<div class="pcard">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<div style="font-size:15px;font-weight:600;">{{ enrollment.institution_name or 'Unknown Bank' }}</div>
<div style="font-size:12px;color:var(--muted);">
Connected {{ enrollment.created_at.strftime('%b %d, %Y') }} ·
Last synced: {{ enrollment.last_synced_at.strftime('%b %d, %H:%M') if enrollment.last_synced_at else 'Never' }}
</div>
</div>
<form method="POST" action="{{ url_for('teller.disconnect', enrollment_db_id=enrollment.id) }}"
onsubmit="return confirm('Disconnect from {{ enrollment.institution_name }}? Existing transactions are kept.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:11px;">Disconnect</button>
</form>
</div>
{% for ta in accounts %}
<div class="d-flex justify-content-between align-items-center py-2" style="border-top:1px solid var(--border);">
<div class="d-flex align-items-center gap-2">
<div style="width:32px;height:32px;border-radius:8px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:14px;">
<i class="bi {% if ta.account_subtype == 'credit_card' %}bi-credit-card{% elif ta.account_subtype == 'savings' %}bi-piggy-bank{% else %}bi-bank{% endif %}"></i>
</div>
<div>
<div style="font-size:13px;font-weight:500;">{{ ta.account_name }}</div>
<div style="font-size:11px;color:var(--muted);">
{{ ta.account_subtype | replace('_',' ') | title }}
{% if ta.pfm_account %}
· Mapped to <strong>{{ ta.pfm_account.name }}</strong>
{% else %}
· <span style="color:#f59e0b;">Not mapped</span>
{% endif %}
</div>
</div>
</div>
<div class="d-flex align-items-center gap-2">
{% if ta.pfm_account %}
<!-- Balance refresh -->
<button onclick="refreshBalance({{ ta.id }}, this)"
class="btn btn-sm btn-outline-secondary" style="font-size:11px;" title="Refresh live balance">
<i class="bi bi-arrow-clockwise"></i>
</button>
<!-- Sync transactions -->
<a href="{{ url_for('teller.sync_preview_view', teller_account_id=ta.id) }}"
class="btn btn-sm btn-outline-primary" style="font-size:11px;">
<i class="bi bi-cloud-download me-1"></i>Sync
</a>
{% else %}
<a href="{{ url_for('teller.map_accounts', enrollment_id=enrollment.enrollment_id) }}"
class="btn btn-sm btn-outline-warning" style="font-size:11px;">Map Account</a>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Sync all -->
{% if accounts | selectattr('pfm_account_id') | list %}
<div class="mt-3 text-end">
<form method="POST" action="{{ url_for('teller.sync_all') }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;">
<i class="bi bi-cloud-download me-1"></i>Sync All Accounts
</button>
</form>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="pcard text-center py-5 col-12 col-lg-6 mx-auto">
<i class="bi bi-bank2 text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No banks connected</h5>
<p class="text-muted small mb-3">
Connect your US bank accounts to automatically sync transactions and balances.
</p>
<button id="tellerConnectBtn2" class="btn btn-primary">
<i class="bi bi-bank me-1"></i>Connect a Bank
</button>
</div>
{% endif %}
<!-- Setup instructions -->
<div class="pcard mt-2" style="background:#f8fafc;">
<div class="pcard-title mb-2">Setup Requirements</div>
<div style="font-size:13px;color:var(--muted);line-height:1.8;">
<div><i class="bi bi-check-circle-fill text-success me-2"></i>Teller App ID set in <code>.env</code></div>
<div><i class="bi bi-check-circle-fill text-success me-2"></i>Client certificate at <code>{{ config.TELLER_CERT_PATH }}</code></div>
<div><i class="bi bi-check-circle-fill text-success me-2"></i>Private key at <code>{{ config.TELLER_KEY_PATH }}</code></div>
<div><i class="bi bi-info-circle text-muted me-2"></i>Environment: <strong>{{ config.TELLER_ENV }}</strong></div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.teller.io/connect/connect.js"></script>
<script>
const CSRF = '{{ csrf_token() }}';
function initTellerConnect(btn) {
if (!btn) return;
btn.addEventListener('click', function() {
const teller = TellerConnect.setup({
applicationId: '{{ config.TELLER_APP_ID }}',
environment: '{{ config.TELLER_ENV }}',
products: ['transactions'],
onSuccess: function(enrollment) {
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>Connecting…';
fetch('/teller/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': CSRF,
},
body: JSON.stringify({ enrollment: enrollment }),
})
.then(r => r.json())
.then(data => {
if (data.redirect) {
window.location.href = data.redirect;
} else {
alert('Connection error: ' + (data.error || 'Unknown'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-bank me-1"></i>Connect a Bank';
}
})
.catch(() => {
alert('Network error. Please try again.');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-bank me-1"></i>Connect a Bank';
});
},
onExit: function() {
console.log('Teller Connect closed');
},
onFailure: function(error) {
alert('Connection failed: ' + error.message);
},
});
teller.open();
});
}
initTellerConnect(document.getElementById('tellerConnectBtn'));
initTellerConnect(document.getElementById('tellerConnectBtn2'));
function refreshBalance(taId, btn) {
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
fetch('/teller/balance/' + taId, {
method: 'POST',
headers: { 'X-CSRFToken': CSRF },
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
if (data.balance != null) {
const sym = '{{ current_user.currency_symbol }}';
btn.title = 'Balance: ' + sym + parseFloat(data.balance).toLocaleString(undefined, {minimumFractionDigits:2});
btn.style.color = '#10b981';
setTimeout(() => btn.style.color = '', 3000);
} else {
btn.title = data.error || 'Failed';
btn.style.color = '#ef4444';
setTimeout(() => btn.style.color = '', 3000);
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
});
}
</script>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Map Accounts{% endblock %}
{% block page_title %}Map Bank Accounts{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-lg-7">
<div class="pcard mb-3" style="background:#f0fdf4;border-color:#bbf7d0;">
<div style="font-size:13px;color:#166534;">
<i class="bi bi-check-circle-fill me-2"></i>
<strong>{{ enrollment.institution_name }}</strong> connected successfully.
Map each account below to a PFM account, or create a new one automatically.
</div>
</div>
<div class="pcard">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% for ta in teller_accounts %}
<div class="mb-4 pb-4" style="{% if not loop.last %}border-bottom:1px solid var(--border);{% endif %}">
<div class="d-flex align-items-center gap-2 mb-2">
<div style="width:36px;height:36px;border-radius:9px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:16px;">
<i class="bi {% if ta.account_subtype == 'credit_card' %}bi-credit-card{% elif ta.account_subtype == 'savings' %}bi-piggy-bank{% else %}bi-bank{% endif %}"></i>
</div>
<div>
<div style="font-size:14px;font-weight:600;">{{ ta.account_name }}</div>
<div style="font-size:11px;color:var(--muted);">
{{ ta.institution_name }} · {{ ta.account_subtype | replace('_',' ') | title }}
· ID: <span class="mono">{{ ta.teller_account_id[-8:] }}</span>
</div>
</div>
</div>
<label class="form-label" style="font-size:13px;font-weight:500;">Map to PFM Account</label>
<select name="pfm_account_{{ ta.id }}" class="form-select form-select-sm">
<option value="">— Skip this account —</option>
<option value="new" {% if not pfm_accounts %}selected{% endif %}>
✨ Create new account automatically
</option>
{% for acct in pfm_accounts %}
<option value="{{ acct.id }}" {% if ta.pfm_account_id == acct.id %}selected{% endif %}>
{{ acct.name }} ({{ acct.account_type | replace('_',' ') | title }})
</option>
{% endfor %}
</select>
<small class="text-muted" style="font-size:11px;">
"Create new" auto-names the account "{{ ta.institution_name }} — {{ ta.account_name }}"
</small>
</div>
{% endfor %}
<div class="d-flex gap-2 mt-2">
<button type="submit" class="btn btn-primary">Save Mapping & Continue</button>
<a href="{{ url_for('teller.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+89
View File
@@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}Sync Preview{% endblock %}
{% block page_title %}Sync Preview — {{ ta.account_name }}{% endblock %}
{% block content %}
<div class="pcard mb-3 d-flex justify-content-between align-items-center">
<div>
<div style="font-size:14px;font-weight:600;">{{ ta.institution_name }} — {{ ta.account_name }}</div>
<div style="font-size:12px;color:var(--muted);">
Mapped to: <strong>{{ ta.pfm_account.name }}</strong> ·
{{ count }} transaction(s) found
{% if ta.last_sync_date %}· Last sync: {{ ta.last_sync_date.strftime('%b %d, %Y') }}{% endif %}
</div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('teller.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">Cancel</a>
{% if count > 0 %}
<form method="POST" action="{{ url_for('teller.sync_confirm') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-success" style="font-size:12px;">
<i class="bi bi-check-lg me-1"></i>Import {{ count }} Transaction(s)
</button>
</form>
{% endif %}
</div>
</div>
{% if preview %}
<div class="pcard p-0">
<table class="pfm-table">
<thead>
<tr>
<th style="padding-left:20px;">Date</th>
<th>Description</th>
<th>Type</th>
<th>Category</th>
<th class="text-end" style="padding-right:20px;">Amount</th>
</tr>
</thead>
<tbody>
{% for txn in preview %}
<tr>
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">
{{ txn.date.strftime('%b %d, %Y') }}
</td>
<td style="font-size:13px;">{{ txn.description }}</td>
<td>
<span class="badge {% if txn.transaction_type == 'income' %}badge-income{% else %}badge-expense{% endif %}" style="font-size:11px;">
{{ txn.transaction_type | title }}
</span>
</td>
<td style="font-size:12px;color:var(--muted);">
{% if txn.category_id %}
{% for cat in [txn.category_id] %}
{# Look up category name via the category_id #}
{{ txn.notes | replace('Teller:', '') }}
{% endfor %}
{% else %}
<span style="color:#f59e0b;">Uncategorised</span>
{% endif %}
</td>
<td class="text-end mono {% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
style="font-size:13px;font-weight:600;padding-right:20px;">
{% if txn.transaction_type == 'income' %}+{% else %}-{% endif %}{{ txn.amount | currency }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex justify-content-end mt-3">
<form method="POST" action="{{ url_for('teller.sync_confirm') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i>Confirm Import ({{ count }} transactions)
</button>
</form>
</div>
{% else %}
<div class="pcard text-center py-5">
<i class="bi bi-check-circle text-success" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">All up to date</h5>
<p class="text-muted small">No new transactions found since last sync.</p>
<a href="{{ url_for('teller.index') }}" class="btn btn-sm btn-outline-secondary">Back</a>
</div>
{% endif %}
{% endblock %}