05/31 Teller.io connection
This commit is contained in:
@@ -29,6 +29,7 @@ def create_app(config_name=None):
|
|||||||
from app.routes.ai import ai_bp
|
from app.routes.ai import ai_bp
|
||||||
from app.routes.reports import reports_bp
|
from app.routes.reports import reports_bp
|
||||||
from app.routes.settings import settings_bp
|
from app.routes.settings import settings_bp
|
||||||
|
from app.routes.teller import teller_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
@@ -41,6 +42,7 @@ def create_app(config_name=None):
|
|||||||
app.register_blueprint(ai_bp)
|
app.register_blueprint(ai_bp)
|
||||||
app.register_blueprint(reports_bp)
|
app.register_blueprint(reports_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
|
app.register_blueprint(teller_bp)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -49,6 +51,7 @@ def create_app(config_name=None):
|
|||||||
Investment, InvestmentTransaction, NetWorthSnapshot,
|
Investment, InvestmentTransaction, NetWorthSnapshot,
|
||||||
AiInsight, FxRate
|
AiInsight, FxRate
|
||||||
)
|
)
|
||||||
|
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
||||||
|
|
||||||
app.jinja_env.globals['format_currency'] = format_currency
|
app.jinja_env.globals['format_currency'] = format_currency
|
||||||
app.jinja_env.globals['format_percent'] = format_percent
|
app.jinja_env.globals['format_percent'] = format_percent
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ class Config:
|
|||||||
|
|
||||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
|
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):
|
class DevelopmentConfig(Config):
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ from app.models.investment import Investment, InvestmentTransaction
|
|||||||
from app.models.net_worth_snapshot import NetWorthSnapshot
|
from app.models.net_worth_snapshot import NetWorthSnapshot
|
||||||
from app.models.ai_insight import AiInsight
|
from app.models.ai_insight import AiInsight
|
||||||
from app.models.fx_rate import FxRate
|
from app.models.fx_rate import FxRate
|
||||||
|
|
||||||
|
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
||||||
|
|||||||
@@ -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}>'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -6,6 +6,15 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<!-- Nav cards -->
|
<!-- Nav cards -->
|
||||||
<div class="col-12 col-sm-6 col-lg-3">
|
<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">
|
<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)'">
|
<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>
|
<i class="bi bi-person-circle" style="font-size:2rem;color:#3b82f6;"></i>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIExjCCAq6gAwIBAgIIGLTT6l1wsDMwDQYJKoZIhvcNAQELBQAwYTELMAkGA1UE
|
||||||
|
BhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxDzANBgNVBAcMBkxvbmRvbjEPMA0GA1UE
|
||||||
|
CgwGVGVsbGVyMR4wHAYDVQQLDBVUZWxsZXIgQXBwbGljYXRpb24gQ0EwHhcNMjYw
|
||||||
|
NjAxMDIyNTU2WhcNMjkwNTMxMDIyNTU2WjAkMSIwIAYDVQQDDBlhcHBfcHQwODBj
|
||||||
|
OW52ZThoazBnMWNzMDAwMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
|
||||||
|
wqhPhALJWScrvPx2KeV2wJuuENke8AD1Tstjj2ICmtp3RG3X5TNZshSzQTd9nEN7
|
||||||
|
hwuUR1mZ+Y7LhR1eDlPuVDKzsiyBExVSL2dR6OmuCJmypXNn2yUYI4NkzNclpMk6
|
||||||
|
+oQzW8OpfokzGlr9vPQgiEZqQM7ce3xIFWVnwowSzUCrjjz5pVG5DesQZOxet46P
|
||||||
|
VkSaZ1LvYaOTdqEpzE7yHcFr5umHoiEfhfPYCNfSmhUDWdlo11bGmtAKLWR9LMWU
|
||||||
|
hJKjNUpCawbj3V1xQSDMiHJ5MeccEfGrNAnVhX6cNtMOv20RxTNWk6vu7NjFxY01
|
||||||
|
qAV8FdGP/+UEmHi0Zg1xUwIDAQABo4G+MIG7MA4GA1UdDwEB/wQEAwIF4DATBgNV
|
||||||
|
HSUEDDAKBggrBgEFBQcDAjCBkwYDVR0jBIGLMIGIgBSEq++simSLxXkuNSUKjel6
|
||||||
|
pmhxmqFlpGMwYTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxDzANBgNV
|
||||||
|
BAcMBkxvbmRvbjEPMA0GA1UECgwGVGVsbGVyMR4wHAYDVQQLDBVUZWxsZXIgQXBw
|
||||||
|
bGljYXRpb24gQ0GCCQDiNWG/vm85CTANBgkqhkiG9w0BAQsFAAOCAgEAYyUeMNqU
|
||||||
|
98ZoQ42RIrLDMcF8QZjQHrr7Dg+i0zqAc/VQyO1cCa8KoVVBUtiXSJ2KuBEektWk
|
||||||
|
/Qn24XsprhQn3t/27zv1t3R2BfPqyAFUaj+W4BEJlNhp8Srd+Fb12Y8SghHOaWlN
|
||||||
|
FIsussSV3UrusKPtLzcNN9AGOcA52B4bhvZW9SKy3benvFPDWOlVgCTJ02NXjPYj
|
||||||
|
buOeMtYGROxpjwwqbeghLXRicicJkpNQTWtpnsVysnN9+nARYvhBgMHkEqyPz1Or
|
||||||
|
O/RTZN9rmv8XmbSIGx2PztzHEfijJYalVHr11FBz3f+blecOIXsirw+4lrehAHmu
|
||||||
|
AV3HygQ4CJ7oZdXRxz8U4K4wzlzYnt/EJX5Ki0MzHNdVvcqGMurZVWFJolLG3cNz
|
||||||
|
kOC79sHghnFXam3HS5CKWbKA/ZqDHHYwZXNFoLUu3a3vG7HNPfiPac1o1s0neD3D
|
||||||
|
wL5cQDlpWNzmUkxLRxOXXp9aEcsIh2YidgPeqeLRyHiZQtXDxrKMQM/vadBuVWh/
|
||||||
|
RLgfw0IMEITavgdTqkLauAbkx0LWD6AiARl+cPexR9oJnvOHIcPa8fekFcdUkUZy
|
||||||
|
SiijNfHV520sf+IwufA+Vrv/JSor4+4AsGvvBvwtHSV5/+BBKPp4WcqQ3Ibu+Uxl
|
||||||
|
uiYPzHXmRdTY0GYa9LMolJYQOtl4U4PPvMU=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDCqE+EAslZJyu8
|
||||||
|
/HYp5XbAm64Q2R7wAPVOy2OPYgKa2ndEbdflM1myFLNBN32cQ3uHC5RHWZn5jsuF
|
||||||
|
HV4OU+5UMrOyLIETFVIvZ1Ho6a4ImbKlc2fbJRgjg2TM1yWkyTr6hDNbw6l+iTMa
|
||||||
|
Wv289CCIRmpAztx7fEgVZWfCjBLNQKuOPPmlUbkN6xBk7F63jo9WRJpnUu9ho5N2
|
||||||
|
oSnMTvIdwWvm6YeiIR+F89gI19KaFQNZ2WjXVsaa0AotZH0sxZSEkqM1SkJrBuPd
|
||||||
|
XXFBIMyIcnkx5xwR8as0CdWFfpw20w6/bRHFM1aTq+7s2MXFjTWoBXwV0Y//5QSY
|
||||||
|
eLRmDXFTAgMBAAECggEAElz7Z1o18W2jECiG4yvs+H2XPaql4wFMIvtH8KJP2Zjr
|
||||||
|
cG6mU8500zplsKzR8jhhgltiyRpYTrUcWPnswhBD5viDgDb3lDvKLYOjNAQ7cT9C
|
||||||
|
nZ4V+ZP1w3/lNffD6tg68qAfEjSSnEfIJN/ZmQY0vXZbFrMNFK8kQ7R1xeId8fPS
|
||||||
|
FXXcJ8l2mv5MB9mT8jj9hnawD6YAGF4BjE974lKAimXEFZ97UTxeV5/VlrYPITw7
|
||||||
|
pGARc6amfwTzYs+NbmzSwbu1vVNMmFBezVq4bl62zKpCyoSpSq2WSousmpgEPepZ
|
||||||
|
DXjEx4udtpKdFF0op0oFN1t6N6JvBgYJfYOloWU9OQKBgQDzM0z517fByNUP7RqT
|
||||||
|
evcTodCv7khZhKZTeL3LU+vXH/sKga/9+4EXepffeQy57bvS78Jw1bgc/MYHslPF
|
||||||
|
dZllKi0TexoXJuyoP+NTXu2nO8EZB041nxi0OdHljFXmy+G+mJKQ9rZbZvQOlUuS
|
||||||
|
vVSyyPWWOReyNw+BItICzmJcawKBgQDM5vqdiasCaQK/Gcd98A2bkdPa+Pyu5j1U
|
||||||
|
w9rKKF9X0phgt3+EsZcgKhN6lBKnk/cZvU86RPwstJu0cCLn9LFyTmPYGwm5yPAt
|
||||||
|
1oO2HQiNJ2Hqw/NnSzSEcIp/RcHhdQWFaK17rgd/i7FefW3lEIQEW1EsWnPm/9dL
|
||||||
|
H3oiMpP4uQKBgGNu5xsDarsNTKd9Tq9byCc7sqIrr2MCTCq2pAq83iEPj0llarpS
|
||||||
|
GR6rXerdiCmAnJmKs5oEl4kqhCDjdUK3aScmjlV3sFwk6v+DV4NfvZTxZmrARObB
|
||||||
|
jI0rUrkinoCFfV+667nfVQGb308TFVoClN2gMmDgKOMRhgJLZUgGb8rPAoGAa5Ls
|
||||||
|
il7niClnhrrbEFRCYKWL60+DIbPBCUqWCEJv4+StmUFdUmYGKJ3OgjFRJee5+Cp6
|
||||||
|
eOYU2serY2zn9o1xx8g+BQwU7BQBfJ89oRPXFHxTnPRpSpaiKNII9E7EPkC4uFS/
|
||||||
|
l8pDJ4RIh4okcvlbxgnHMRj/9ovlFQyei2uwYgkCgYEAr3zqkaQghlO89VsFTOpk
|
||||||
|
0KClPSobU9Pyj+0MD56CUg7d4ryNn5o+zKHciUpnx1vxwAtfosbbbLS4rymlN4ZI
|
||||||
|
yn/eumThnMfjL92NpVefIK00NTijqnl1LKWCXzviy9xvLdfDJjJypmDkyW7SNyul
|
||||||
|
AB3VZ08Ajt7q8A1eDMWyfhM=
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
Reference in New Issue
Block a user