diff --git a/app/__init__.py b/app/__init__.py index 31534e7..65023d7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/config.py b/app/config.py index 7438aa5..bc4d62c 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 69f3d62..b2b0fbd 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/teller_enrollment.py b/app/models/teller_enrollment.py new file mode 100644 index 0000000..ae66038 --- /dev/null +++ b/app/models/teller_enrollment.py @@ -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'' + + +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'' diff --git a/app/routes/teller.py b/app/routes/teller.py new file mode 100644 index 0000000..7469696 --- /dev/null +++ b/app/routes/teller.py @@ -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/', 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/', 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/', 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/', 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 diff --git a/app/services/teller_service.py b/app/services/teller_service.py new file mode 100644 index 0000000..3852b9e --- /dev/null +++ b/app/services/teller_service.py @@ -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 diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html index eb8c2de..7d62630 100644 --- a/app/templates/settings/index.html +++ b/app/templates/settings/index.html @@ -6,6 +6,15 @@
+
diff --git a/app/templates/teller/index.html b/app/templates/teller/index.html new file mode 100644 index 0000000..0cc08e1 --- /dev/null +++ b/app/templates/teller/index.html @@ -0,0 +1,192 @@ +{% extends "base.html" %} +{% block title %}Bank Connections{% endblock %} +{% block page_title %}Bank Connections{% endblock %} + +{% block topbar_actions %} + +{% endblock %} + +{% block content %} +{% if enrollments %} + +{% else %} +
+ +
No banks connected
+

+ Connect your US bank accounts to automatically sync transactions and balances. +

+ +
+{% endif %} + + +
+
Setup Requirements
+
+
Teller App ID set in .env
+
Client certificate at {{ config.TELLER_CERT_PATH }}
+
Private key at {{ config.TELLER_KEY_PATH }}
+
Environment: {{ config.TELLER_ENV }}
+
+
+{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/app/templates/teller/map_accounts.html b/app/templates/teller/map_accounts.html new file mode 100644 index 0000000..1e2005f --- /dev/null +++ b/app/templates/teller/map_accounts.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}Map Accounts{% endblock %} +{% block page_title %}Map Bank Accounts{% endblock %} + +{% block content %} +
+
+ +
+
+ + {{ enrollment.institution_name }} connected successfully. + Map each account below to a PFM account, or create a new one automatically. +
+
+ +
+
+ + + {% for ta in teller_accounts %} +
+
+
+ +
+
+
{{ ta.account_name }}
+
+ {{ ta.institution_name }} · {{ ta.account_subtype | replace('_',' ') | title }} + · ID: {{ ta.teller_account_id[-8:] }} +
+
+
+ + + + + "Create new" auto-names the account "{{ ta.institution_name }} — {{ ta.account_name }}" + +
+ {% endfor %} + +
+ + Cancel +
+
+
+
+
+{% endblock %} diff --git a/app/templates/teller/preview.html b/app/templates/teller/preview.html new file mode 100644 index 0000000..362b0b2 --- /dev/null +++ b/app/templates/teller/preview.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} +{% block title %}Sync Preview{% endblock %} +{% block page_title %}Sync Preview — {{ ta.account_name }}{% endblock %} + +{% block content %} +
+
+
{{ ta.institution_name }} — {{ ta.account_name }}
+
+ Mapped to: {{ ta.pfm_account.name }} · + {{ count }} transaction(s) found + {% if ta.last_sync_date %}· Last sync: {{ ta.last_sync_date.strftime('%b %d, %Y') }}{% endif %} +
+
+
+ Cancel + {% if count > 0 %} +
+ + +
+ {% endif %} +
+
+ +{% if preview %} +
+ + + + + + + + + + + + {% for txn in preview %} + + + + + + + + {% endfor %} + +
DateDescriptionTypeCategoryAmount
+ {{ txn.date.strftime('%b %d, %Y') }} + {{ txn.description }} + + {{ txn.transaction_type | title }} + + + {% if txn.category_id %} + {% for cat in [txn.category_id] %} + {# Look up category name via the category_id #} + {{ txn.notes | replace('Teller:', '') }} + {% endfor %} + {% else %} + Uncategorised + {% endif %} + + {% if txn.transaction_type == 'income' %}+{% else %}-{% endif %}{{ txn.amount | currency }} +
+
+ +
+
+ + +
+
+ +{% else %} +
+ +
All up to date
+

No new transactions found since last sync.

+ Back +
+{% endif %} +{% endblock %} diff --git a/teller/certificate.pem b/teller/certificate.pem new file mode 100644 index 0000000..adaf6bb --- /dev/null +++ b/teller/certificate.pem @@ -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----- + diff --git a/teller/private_key.pem b/teller/private_key.pem new file mode 100644 index 0000000..88d1ff7 --- /dev/null +++ b/teller/private_key.pem @@ -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-----