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

384 lines
14 KiB
Python

"""
Plaid Integration Service
Auth: PLAID-CLIENT-ID + PLAID-SECRET headers on every request.
Key endpoints:
POST /link/token/create → link_token for the frontend widget
POST /item/public_token/exchange → access_token + item_id
POST /accounts/get → list accounts in an item
POST /accounts/balance/get → live balances
POST /liabilities/get → credit card due date, min payment
POST /transactions/sync → cursor-based incremental sync
Sign convention (ALL account types):
positive amount → money OUT of the account → expense
negative amount → money INTO the account → income
"""
import logging
from datetime import date, datetime, timedelta
import requests
from flask import current_app
log = logging.getLogger(__name__)
PLAID_HOSTS = {
'sandbox': 'https://sandbox.plaid.com',
'production': 'https://production.plaid.com',
# 'development' was sunset by Plaid — map it to production so old configs don't break
'development': 'https://production.plaid.com',
}
# Plaid top-level category → PFM category name
CATEGORY_MAP = {
'Food and Drink': 'Food & Dining',
'Travel': 'Transport',
'Shops': 'Shopping',
'Recreation': 'Entertainment',
'Healthcare': 'Health',
'Service': 'Other',
'Community': 'Other',
'Bank Fees': 'Other',
'Cash Advance': 'Other',
'Interest': 'Other',
'Payment': 'Other',
'Transfer': 'Other',
'Tax': 'Other',
'Payroll': 'Salary',
'Deposit': 'Other Income',
'Income': 'Other Income',
'Investment Income': 'Investment',
'Utilities': 'Utilities',
'Telecommunication': 'Utilities',
'Insurance': 'Insurance',
'Education': 'Education',
'Rent and Utilities': 'Housing',
'Mortgage': 'Housing',
'Home Improvement': 'Housing',
'Government and Non-Profit': 'Other',
}
def _base():
env = current_app.config.get('PLAID_ENV', 'sandbox').lower()
return PLAID_HOSTS.get(env, PLAID_HOSTS['sandbox'])
def _headers():
return {
'PLAID-CLIENT-ID': current_app.config.get('PLAID_CLIENT_ID', ''),
'PLAID-SECRET': current_app.config.get('PLAID_SECRET', ''),
'Content-Type': 'application/json',
}
def _post(path, payload):
url = f'{_base()}{path}'
resp = requests.post(url, json=payload, headers=_headers(), timeout=30)
if not resp.ok:
log.error('[plaid] API error %s %s — body=%r',
resp.status_code, path, resp.text[:500])
resp.raise_for_status()
return resp.json()
# ── Link Token ────────────────────────────────────────────────────────────────
def create_link_token():
"""Create a Link token for the frontend Plaid Link widget."""
data = _post('/link/token/create', {
'user': {'client_user_id': 'pfm-user'},
'client_name': 'Personal Finance Manager',
'products': ['transactions'],
'additional_consented_products': ['liabilities'],
'country_codes': ['US'],
'language': 'en',
})
return data['link_token']
# ── Token Exchange ────────────────────────────────────────────────────────────
def exchange_public_token(public_token):
"""Exchange the one-time public_token for a permanent access_token."""
return _post('/item/public_token/exchange', {'public_token': public_token})
# Returns: {'access_token': '...', 'item_id': '...'}
# ── Accounts ──────────────────────────────────────────────────────────────────
def get_accounts(item):
"""Return list of account dicts for the item (cached, not live balances)."""
data = _post('/accounts/get', {'access_token': item.access_token})
return data.get('accounts', [])
def get_balances(item, plaid_account_ids=None):
"""
Return live balance data keyed by plaid_account_id.
Optionally filter to a subset of account IDs.
"""
payload = {'access_token': item.access_token}
if plaid_account_ids:
payload['options'] = {'account_ids': plaid_account_ids}
data = _post('/accounts/balance/get', payload)
return {a['account_id']: a['balances'] for a in data.get('accounts', [])}
# ── Liabilities (credit cards) ────────────────────────────────────────────────
def get_liabilities(item):
"""
Fetch credit card billing details for all credit accounts in the item.
Returns list of liability dicts; empty list if the item has no credit accounts
or if the product is not supported.
Key fields per entry:
account_id, minimum_payment_amount, next_payment_due_date,
last_statement_balance, last_statement_issue_date, is_overdue
"""
try:
data = _post('/liabilities/get', {'access_token': item.access_token})
return data.get('liabilities', {}).get('credit', [])
except requests.HTTPError as e:
# PRODUCTS_NOT_SUPPORTED or institution doesn't support liabilities
log.warning('[plaid] liabilities not available for item %s: %s', item.item_id, e)
return []
def refresh_liabilities(item):
"""
Pull latest credit card liabilities and persist them onto PlaidAccount rows.
Returns number of accounts updated.
"""
from app.extensions import db
liabs = get_liabilities(item)
if not liabs:
return 0
from app.models.plaid_item import PlaidAccount
updated = 0
for lib in liabs:
pa = PlaidAccount.query.filter_by(
plaid_account_id=lib['account_id'], is_active=True
).first()
if not pa:
continue
due_raw = lib.get('next_payment_due_date')
pa.cc_due_date = (
datetime.strptime(due_raw, '%Y-%m-%d').date() if due_raw else None
)
pa.cc_minimum_payment = lib.get('minimum_payment_amount')
pa.cc_last_statement_balance = lib.get('last_statement_balance')
pa.cc_is_overdue = bool(lib.get('is_overdue', False))
pa.cc_updated_at = datetime.utcnow()
updated += 1
db.session.commit()
log.info('[plaid] liabilities updated for %d account(s) in item %s',
updated, item.item_id)
return updated
# ── Transactions Sync ─────────────────────────────────────────────────────────
def sync_transactions(item):
"""
Cursor-based transaction sync. Fetches ALL pages until has_more=False.
Returns (added, modified, removed, next_cursor).
Passing an empty/None cursor fetches full available history.
"""
added = []
modified = []
removed = []
cursor = item.cursor or ''
has_more = True
while has_more:
data = _post('/transactions/sync', {
'access_token': item.access_token,
'cursor': cursor,
})
added.extend(data.get('added', []))
modified.extend(data.get('modified', []))
removed.extend(data.get('removed', []))
cursor = data.get('next_cursor', cursor)
has_more = data.get('has_more', False)
return added, modified, removed, cursor
def build_category_map():
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 _map_plaid_category(plaid_cats, txn_type):
"""Map Plaid category array to a PFM category name."""
if not plaid_cats:
return None
top = plaid_cats[0]
mapped = CATEGORY_MAP.get(top)
if mapped:
return mapped
# Auto-categorize by description is done downstream; fall back by type
return 'Other Income' if txn_type == 'income' else None
def parse_transaction(plaid_txn, plaid_account_map, cat_id_map):
"""
Convert a Plaid transaction dict to a PFM-ready dict.
plaid_account_map: {plaid_account_id: pfm_account_id}
"""
amount_raw = float(plaid_txn.get('amount', 0))
# Plaid: positive = expense (debit/outflow), negative = income (credit/inflow)
if amount_raw > 0:
txn_type = 'expense'
amount = amount_raw
else:
txn_type = 'income'
amount = abs(amount_raw)
description = (
plaid_txn.get('merchant_name') or
plaid_txn.get('name') or
'Plaid transaction'
).strip()
# Auto-categorize via keyword match first, then Plaid category
from app.services.bank_import_service import auto_categorize
pfm_cat_name = auto_categorize(description)
if not pfm_cat_name:
pfm_cat_name = _map_plaid_category(plaid_txn.get('category') or [], txn_type)
category_id = cat_id_map.get(pfm_cat_name) if pfm_cat_name else None
plaid_acct_id = plaid_txn.get('account_id', '')
pfm_account_id = plaid_account_map.get(plaid_acct_id)
txn_id = plaid_txn.get('transaction_id', '')
return {
'plaid_id': txn_id,
'plaid_account_id': plaid_acct_id,
'date': datetime.strptime(plaid_txn['date'], '%Y-%m-%d').date(),
'transaction_type': txn_type,
'amount': amount,
'description': description,
'account_id': pfm_account_id,
'category_id': category_id,
'notes': f'Plaid:{txn_id}',
'pending': plaid_txn.get('pending', False),
}
def sync_preview(item):
"""
Fetch new transactions for an item and return a list of parsed preview dicts.
Does NOT write to DB or advance the cursor — call this before showing the preview.
Returns (parsed_list, next_cursor).
"""
from app.models.plaid_item import PlaidAccount
added, _modified, _removed, next_cursor = sync_transactions(item)
# Build maps
plaid_accounts = PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all()
plaid_account_map = {pa.plaid_account_id: pa.pfm_account_id for pa in plaid_accounts}
cat_map = build_category_map()
parsed = []
for txn in added:
if txn.get('pending', False):
continue # skip pending transactions — import after they post
p = parse_transaction(txn, plaid_account_map, cat_map)
if p['account_id'] is None:
continue # skip accounts not mapped to a PFM account
parsed.append(p)
return parsed, next_cursor
def import_transactions(parsed_txns, next_cursor, item):
"""
Write selected parsed transactions to the DB.
Skips duplicates by checking Plaid:<transaction_id> in notes.
Advances item cursor to next_cursor after successful import.
Returns (imported_count, skipped_count).
"""
from app.extensions import db
from app.models.transaction import Transaction
from app.services.account_service import calc_balance
from app.models.plaid_item import PlaidAccount
imported = skipped = 0
affected_accounts = set()
plaid_account_ids_synced = set()
for p in parsed_txns:
pid = p['plaid_id']
if Transaction.query.filter(Transaction.notes.like(f'%Plaid:{pid}%')).first():
skipped += 1
continue
db.session.add(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 = p['notes'],
))
if p['account_id']:
affected_accounts.add(p['account_id'])
plaid_account_ids_synced.add(p['plaid_account_id'])
imported += 1
db.session.commit()
# Advance cursor
item.cursor = next_cursor
item.last_synced_at = datetime.utcnow()
# Update last_sync_date per PlaidAccount
today = date.today()
for pa in PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all():
if pa.plaid_account_id in plaid_account_ids_synced:
pa.last_sync_date = today
db.session.commit()
# Refresh balances for mapped accounts
if affected_accounts:
try:
mapped_pa = PlaidAccount.query.filter(
PlaidAccount.pfm_account_id.in_(affected_accounts),
PlaidAccount.is_active == True,
).all()
if mapped_pa:
pa_ids = [pa.plaid_account_id for pa in mapped_pa]
bal_map = get_balances(item, pa_ids)
for pa in mapped_pa:
bal = bal_map.get(pa.plaid_account_id, {})
current = bal.get('current')
available = bal.get('available')
if current is not None:
is_cc = pa.account_type == 'credit' or pa.account_subtype == 'credit card'
if is_cc:
# Plaid returns positive current balance = amount owed on card
pa.pfm_account.balance = -abs(float(current))
else:
pa.pfm_account.balance = float(available if available is not None else current)
db.session.commit()
except Exception as e:
log.warning('[plaid] balance refresh after import failed: %s', e)
for acct_id in affected_accounts:
calc_balance(acct_id)
return imported, skipped