Files
Personal-Finance-Management/app/services/teller_service.py
T

360 lines
13 KiB
Python

"""
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', '')
log.debug(f'[teller] _session: cert={cert_path!r} key={key_path!r}')
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 _raise_for_status_with_log(resp, context=''):
"""Call raise_for_status() but log the response body first on error."""
if not resp.ok:
log.error(
'[teller] API error%s — status=%s url=%s body=%r',
f' ({context})' if context else '',
resp.status_code,
resp.url,
resp.text[:2000],
)
resp.raise_for_status()
def get_accounts(access_token):
"""
Fetch all accounts for an enrollment.
Returns list of account dicts or raises on error.
"""
log.info('[teller] get_accounts: requesting %s/accounts', TELLER_BASE)
session = _session(access_token)
try:
resp = session.get(f'{TELLER_BASE}/accounts', timeout=15)
except requests.exceptions.RequestException as e:
log.error('[teller] get_accounts: request failed: %s', e, exc_info=True)
raise
_raise_for_status_with_log(resp, 'get_accounts')
data = resp.json()
log.info('[teller] get_accounts: returned %d account(s)', len(data))
return data
def get_balance(access_token, account_id):
"""Fetch live balance for a single account."""
log.info('[teller] get_balance: account=%s', account_id)
session = _session(access_token)
try:
resp = session.get(f'{TELLER_BASE}/accounts/{account_id}/balances', timeout=15)
except requests.exceptions.RequestException as e:
log.error('[teller] get_balance: request failed: %s', e, exc_info=True)
raise
_raise_for_status_with_log(resp, f'get_balance account={account_id}')
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
log.info('[teller] get_transactions: account=%s params=%s', account_id, params)
try:
resp = session.get(
f'{TELLER_BASE}/accounts/{account_id}/transactions',
params=params,
timeout=30,
)
except requests.exceptions.RequestException as e:
log.error('[teller] get_transactions: request failed: %s', e, exc_info=True)
raise
_raise_for_status_with_log(resp, f'get_transactions account={account_id}')
data = resp.json()
log.info('[teller] get_transactions: returned %d transaction(s)', len(data))
return data
def parse_transaction(teller_txn, pfm_account_id, category_id_map, is_credit_card=False):
"""
Convert a Teller transaction dict to a PFM transaction dict ready for import.
Returns dict with keys matching Transaction model fields.
Teller sign convention differs by account type:
- Depository (checking/savings): positive = inflow (income), negative = outflow (expense)
- Credit (credit card): positive = charge/purchase (expense), negative = payment/credit (income)
"""
amount_raw = float(teller_txn.get('amount', 0))
if is_credit_card:
# Credit cards: positive amount = purchase (expense), negative = payment (income)
if amount_raw > 0:
txn_type = 'expense'
amount = amount_raw
else:
txn_type = 'income'
amount = abs(amount_raw)
else:
# Depository: positive = deposit (income), negative = withdrawal (expense)
if amount_raw < 0:
txn_type = 'expense'
amount = abs(amount_raw)
else:
txn_type = 'income'
amount = 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
# Auto-categorize: keyword match on description first (most accurate),
# then fall back to Teller's own category field.
from app.services.bank_import_service import auto_categorize
pfm_cat_name = auto_categorize(description)
if not pfm_cat_name:
teller_cat = (details.get('category') or '').lower()
pfm_cat_name = CATEGORY_MAP.get(teller_cat, '')
category_id = category_id_map.get(pfm_cat_name) if pfm_cat_name else None
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:
body = e.response.text[:2000] if e.response is not None else '(no response)'
log.error(
'[teller] fetch failed for %s: %s — body=%r',
teller_account.teller_account_id, e, body, exc_info=True,
)
raise
cat_map = build_category_map()
is_cc = (
teller_account.account_type == 'credit' or
teller_account.account_subtype == 'credit_card' or
(teller_account.pfm_account and
teller_account.pfm_account.account_type == 'credit_card')
)
parsed = []
for txn in raw_txns:
p = parse_transaction(txn, teller_account.pfm_account_id, cat_map,
is_credit_card=is_cc)
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
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 on both the account and its parent enrollment
from datetime import datetime
now = datetime.utcnow()
teller_account.last_sync_date = date.today()
teller_account.enrollment.last_synced_at = now
if parsed_txns:
teller_account.last_teller_txn_id = parsed_txns[0]['teller_id']
db.session.commit()
# Refresh balance from Teller live API (source of truth for linked accounts).
# Fall back to transaction-computed balance only if the API call fails.
if teller_account.pfm_account_id:
try:
bal_data = get_balance(teller_account.enrollment.access_token,
teller_account.teller_account_id)
available = float(bal_data.get('available') or bal_data.get('ledger') or 0)
ledger = float(bal_data.get('ledger') or bal_data.get('available') or 0)
is_credit = teller_account.pfm_account.account_type == 'credit_card'
teller_account.pfm_account.balance = -abs(ledger) if is_credit else available
db.session.commit()
log.info('[teller] balance refreshed after sync for %s: %.2f',
teller_account.account_name, float(teller_account.pfm_account.balance))
except Exception as e:
log.warning('[teller] live balance unavailable after sync, falling back to '
'calc_balance: %s', e)
for account_id in affected_accounts:
calc_balance(account_id)
return imported, skipped