360 lines
13 KiB
Python
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
|