05/31 Teller.io connection
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user