06/05 Optimize app: implement Plaid

This commit is contained in:
2026-06-05 09:52:11 -04:00
parent 8481e9c214
commit fbfb7ab33c
15 changed files with 1612 additions and 15 deletions
+4
View File
@@ -9,3 +9,7 @@ APP_CURRENCY=USD
APP_CURRENCY_SYMBOL=$
APP_TIMEZONE=Asia/Ho_Chi_Minh
FLASK_APP=wsgi:app
# Plaid (sandbox / development / production)
PLAID_CLIENT_ID=your-plaid-client-id
PLAID_SECRET=your-plaid-secret
PLAID_ENV=sandbox
+3
View File
@@ -104,6 +104,7 @@ def create_app(config_name=None):
from app.routes.settings import settings_bp
from app.routes.teller import teller_bp
from app.routes.schwab import schwab_bp
from app.routes.plaid import plaid_bp
from app.routes.logs import logs_bp
from app.routes.bank_import import bank_import_bp
@@ -120,6 +121,7 @@ def create_app(config_name=None):
app.register_blueprint(settings_bp)
app.register_blueprint(teller_bp)
app.register_blueprint(schwab_bp)
app.register_blueprint(plaid_bp)
app.register_blueprint(logs_bp)
app.register_blueprint(bank_import_bp)
@@ -132,6 +134,7 @@ def create_app(config_name=None):
)
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
from app.models.schwab_connection import SchwabConnection, SchwabAccount
from app.models.plaid_item import PlaidItem, PlaidAccount, PlaidSyncPreview
# ── Session idle timeout ──────────────────────────────────────────────────
from flask import session as _session, request as _request
+5
View File
@@ -30,6 +30,11 @@ class Config:
TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem')
TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '')
# Plaid (sandbox / development / production)
PLAID_CLIENT_ID = os.environ.get('PLAID_CLIENT_ID', '')
PLAID_SECRET = os.environ.get('PLAID_SECRET', '')
PLAID_ENV = os.environ.get('PLAID_ENV', 'sandbox')
# Schwab Developer API (OAuth 2.0)
SCHWAB_CLIENT_ID = os.environ.get('SCHWAB_CLIENT_ID', '')
SCHWAB_CLIENT_SECRET = os.environ.get('SCHWAB_CLIENT_SECRET', '')
+77
View File
@@ -0,0 +1,77 @@
from datetime import datetime
from app.extensions import db
from app.utils.crypto import EncryptedText
class PlaidItem(db.Model):
"""One Plaid Item = one bank connection (may contain multiple accounts)."""
__tablename__ = 'plaid_items'
id = db.Column(db.Integer, primary_key=True)
item_id = db.Column(db.String(100), unique=True, nullable=False, index=True)
access_token = db.Column(EncryptedText, nullable=False)
institution_id = db.Column(db.String(50), nullable=True)
institution_name = db.Column(db.String(100), nullable=True)
cursor = db.Column(db.String(500), nullable=True) # /transactions/sync cursor
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('PlaidAccount', back_populates='item',
cascade='all, delete-orphan', lazy='dynamic')
def __repr__(self):
return f'<PlaidItem {self.institution_name} ({self.item_id[:12]}…)>'
class PlaidAccount(db.Model):
"""Maps one Plaid account inside an item to a PFM account."""
__tablename__ = 'plaid_accounts'
id = db.Column(db.Integer, primary_key=True)
item_id = db.Column(db.Integer, db.ForeignKey('plaid_items.id'), nullable=False)
plaid_account_id = db.Column(db.String(100), 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 / investment
account_subtype = db.Column(db.String(50), nullable=True) # checking / savings / credit card
mask = db.Column(db.String(10), nullable=True) # last 4 digits
last_sync_date = db.Column(db.Date, nullable=True)
# Credit card billing info (populated from /liabilities/get)
cc_due_date = db.Column(db.Date, nullable=True)
cc_minimum_payment = db.Column(db.Numeric(12, 2), nullable=True)
cc_last_statement_balance = db.Column(db.Numeric(12, 2), nullable=True)
cc_is_overdue = db.Column(db.Boolean, default=False)
cc_updated_at = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
item = db.relationship('PlaidItem', back_populates='accounts')
pfm_account = db.relationship('Account')
@property
def display_name(self):
suffix = f' ••••{self.mask}' if self.mask else ''
return f'{self.account_name}{suffix}'
def __repr__(self):
return f'<PlaidAccount {self.account_name} → PFM #{self.pfm_account_id}>'
class PlaidSyncPreview(db.Model):
"""Temporary server-side storage for Plaid sync preview data (per item)."""
__tablename__ = 'plaid_sync_previews'
id = db.Column(db.Integer, primary_key=True)
item_id = db.Column(db.Integer, db.ForeignKey('plaid_items.id'),
nullable=False, unique=True, index=True)
data_json = db.Column(db.Text, nullable=False) # JSON array of parsed transactions
next_cursor = db.Column(db.String(500), nullable=True) # advance item cursor on confirm
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<PlaidSyncPreview item_id={self.item_id}>'
+12 -3
View File
@@ -61,11 +61,12 @@ def index():
from app.models.teller_enrollment import TellerAccount
from app.models.schwab_connection import SchwabAccount
from app.models.plaid_item import PlaidAccount
all_accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
pfm_ids = [a.id for a in all_accounts]
# Build sync-provider maps before the calc_balance loop.
# Accounts linked to Teller or Schwab get their balance from the provider,
# Accounts linked to Teller, Schwab, or Plaid get their balance from the provider,
# not from transaction summation, so we skip calc_balance for them.
teller_accounts = TellerAccount.query.filter(
TellerAccount.pfm_account_id.in_(pfm_ids),
@@ -79,7 +80,13 @@ def index():
).all()
schwab_map = {sa.pfm_account_id: sa for sa in schwab_accounts}
provider_ids = set(teller_map) | set(schwab_map)
plaid_accounts = PlaidAccount.query.filter(
PlaidAccount.pfm_account_id.in_(pfm_ids),
PlaidAccount.is_active == True,
).all()
plaid_map = {pa.pfm_account_id: pa for pa in plaid_accounts}
provider_ids = set(teller_map) | set(schwab_map) | set(plaid_map)
for a in all_accounts:
if a.id not in provider_ids:
calc_balance(a.id)
@@ -111,7 +118,9 @@ def index():
credit_count=len(credit_accounts),
monthly_charges=monthly_charges,
teller_map=teller_map,
schwab_map=schwab_map)
schwab_map=schwab_map,
plaid_map=plaid_map,
today=date.today())
@accounts_bp.route('/new', methods=['GET', 'POST'])
+383
View File
@@ -0,0 +1,383 @@
import json
import logging
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, jsonify, session, current_app)
from flask_login import login_required
from app.extensions import db
from app.models.account import Account
from app.models.plaid_item import PlaidItem, PlaidAccount, PlaidSyncPreview
from app.services.plaid_service import (
create_link_token, exchange_public_token,
get_accounts, get_balances,
refresh_liabilities, sync_preview, import_transactions,
)
plaid_bp = Blueprint('plaid', __name__, url_prefix='/plaid')
log = logging.getLogger(__name__)
def _configured():
return bool(current_app.config.get('PLAID_CLIENT_ID') and
current_app.config.get('PLAID_SECRET'))
# ── Index ─────────────────────────────────────────────────────────────────────
@plaid_bp.route('/')
@login_required
def index():
from datetime import date
items = PlaidItem.query.filter_by(is_active=True).all()
return render_template('plaid/index.html',
items=items,
plaid_configured=_configured(),
today=date.today())
# ── Link Token (AJAX) ─────────────────────────────────────────────────────────
@plaid_bp.route('/create-link-token', methods=['POST'])
@login_required
def create_link_token_view():
"""AJAX: create a Plaid Link token for the frontend widget."""
if not _configured():
return jsonify({'error': 'Plaid is not configured — add PLAID_CLIENT_ID and PLAID_SECRET to .env'}), 400
try:
token = create_link_token()
return jsonify({'link_token': token})
except Exception as e:
log.error('[plaid] create_link_token failed: %s', e, exc_info=True)
return jsonify({'error': str(e)}), 500
# ── Token Exchange (AJAX, called by frontend after Link success) ──────────────
@plaid_bp.route('/exchange-token', methods=['POST'])
@login_required
def exchange_token():
"""
Called by the frontend after Plaid Link succeeds.
Body: { public_token, institution_id, institution_name, accounts: [...] }
"""
data = request.get_json()
if not data or not data.get('public_token'):
return jsonify({'error': 'Missing public_token'}), 400
public_token = data['public_token']
institution_id = data.get('institution_id', '')
institution_name = data.get('institution_name', 'Unknown Bank')
try:
token_data = exchange_public_token(public_token)
except Exception as e:
log.error('[plaid] exchange_public_token failed: %s', e, exc_info=True)
return jsonify({'error': f'Token exchange failed: {e}'}), 500
access_token = token_data['access_token']
item_id = token_data['item_id']
# Upsert PlaidItem (reconnect preserves existing accounts)
item = PlaidItem.query.filter_by(item_id=item_id).first()
if item:
item.access_token = access_token
item.is_active = True
item.institution_name = institution_name
item.institution_id = institution_id
else:
item = PlaidItem(
item_id = item_id,
access_token = access_token,
institution_id = institution_id,
institution_name = institution_name,
)
db.session.add(item)
db.session.flush()
# Fetch accounts from Plaid and upsert PlaidAccount rows
try:
plaid_accounts = get_accounts(item)
except Exception as e:
log.error('[plaid] get_accounts failed: %s', e, exc_info=True)
db.session.rollback()
return jsonify({'error': f'Could not fetch accounts: {e}'}), 500
for pa_data in plaid_accounts:
pa = PlaidAccount.query.filter_by(plaid_account_id=pa_data['account_id']).first()
if pa:
pa.item = item
pa.is_active = True
else:
db.session.add(PlaidAccount(
item = item,
plaid_account_id = pa_data['account_id'],
account_name = pa_data.get('name', ''),
account_type = pa_data.get('type', ''),
account_subtype = pa_data.get('subtype', ''),
mask = pa_data.get('mask', ''),
))
db.session.commit()
log.info('[plaid] connected item %s (%s) with %d account(s)',
item_id, institution_name, len(plaid_accounts))
return jsonify({'redirect': url_for('plaid.map_accounts', item_db_id=item.id)})
# ── Account Mapping ───────────────────────────────────────────────────────────
@plaid_bp.route('/map/<int:item_db_id>', methods=['GET', 'POST'])
@login_required
def map_accounts(item_db_id):
item = db.get_or_404(PlaidItem, item_db_id)
plaid_accounts = item.accounts.filter_by(is_active=True).all()
pfm_accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
TYPE_MAP = {
'depository': 'checking',
'credit': 'credit_card',
'investment': 'investment',
'loan': 'other',
'other': 'other',
}
SUBTYPE_MAP = {
'checking': 'checking',
'savings': 'savings',
'credit card': 'credit_card',
'money market':'savings',
'cd': 'savings',
'brokerage': 'investment',
'ira': 'investment',
'401k': 'investment',
}
if request.method == 'POST':
for pa in plaid_accounts:
val = request.form.get(f'pfm_account_{pa.id}', '')
if val == 'new':
pfm_type = SUBTYPE_MAP.get(pa.account_subtype or '',
TYPE_MAP.get(pa.account_type or '', 'other'))
new_acct = Account(
name = f'{item.institution_name}{pa.display_name}',
account_type = pfm_type,
color = '#8B5CF6',
icon = 'bi-bank',
balance = 0,
)
db.session.add(new_acct)
db.session.flush()
pa.pfm_account_id = new_acct.id
elif val.isdigit():
acct_id = int(val)
if Account.query.filter_by(id=acct_id, is_active=True).first():
pa.pfm_account_id = acct_id
# val == '' → skip (leave unmapped)
db.session.commit()
mapped = [pa for pa in plaid_accounts if pa.pfm_account_id]
if not mapped:
flash('Select at least one account to map.', 'warning')
return redirect(url_for('plaid.map_accounts', item_db_id=item_db_id))
flash(f'{len(mapped)} account(s) mapped. Ready to sync.', 'success')
return redirect(url_for('plaid.index'))
return render_template('plaid/map_accounts.html',
item=item,
plaid_accounts=plaid_accounts,
pfm_accounts=pfm_accounts)
# ── Sync Preview ──────────────────────────────────────────────────────────────
@plaid_bp.route('/sync/<int:item_db_id>')
@login_required
def sync_preview_view(item_db_id):
item = db.get_or_404(PlaidItem, item_db_id)
try:
parsed, next_cursor = sync_preview(item)
except Exception as e:
log.error('[plaid] sync_preview failed for item id=%s: %s', item_db_id, e, exc_info=True)
flash(f'Sync failed: {e}', 'danger')
return redirect(url_for('plaid.index'))
if not parsed:
flash(f'{item.institution_name}: no new transactions since last sync.', 'info')
# Still advance the cursor so we don't re-fetch old history
item.cursor = next_cursor
item.last_synced_at = datetime.utcnow()
db.session.commit()
return redirect(url_for('plaid.index'))
# Persist preview to DB (avoid session size limits)
preview_data = [
{
'plaid_id': p['plaid_id'],
'plaid_account_id': p['plaid_account_id'],
'date': p['date'].isoformat(),
'transaction_type': p['transaction_type'],
'amount': float(p['amount']),
'description': p['description'],
'account_id': p['account_id'],
'category_id': p['category_id'],
'notes': p['notes'],
}
for p in parsed
]
sp = PlaidSyncPreview.query.filter_by(item_id=item_db_id).first()
if sp:
sp.data_json = json.dumps(preview_data)
sp.next_cursor = next_cursor
sp.created_at = datetime.utcnow()
else:
sp = PlaidSyncPreview(
item_id = item_db_id,
data_json = json.dumps(preview_data),
next_cursor = next_cursor,
)
db.session.add(sp)
db.session.commit()
session['plaid_preview_id'] = sp.id
session['plaid_preview_item'] = item_db_id
from app.models.category import Category
cats = Category.query.filter_by(is_active=True).order_by(Category.name).all()
cat_map = {c.id: c.name for c in cats}
# Group transactions by PlaidAccount for display
plaid_accounts = {
pa.plaid_account_id: pa
for pa in item.accounts.filter_by(is_active=True).all()
}
return render_template('plaid/preview.html',
item=item,
preview=parsed,
count=len(parsed),
cat_map=cat_map,
categories=cats,
plaid_accounts=plaid_accounts)
# ── Sync Confirm ──────────────────────────────────────────────────────────────
@plaid_bp.route('/sync/confirm', methods=['POST'])
@login_required
def sync_confirm():
preview_id = session.pop('plaid_preview_id', None)
item_db_id = session.pop('plaid_preview_item', None)
if not preview_id or not item_db_id:
flash('No pending import. Please sync again.', 'warning')
return redirect(url_for('plaid.index'))
sp = db.session.get(PlaidSyncPreview, preview_id)
if not sp:
flash('Preview expired or already imported. Please sync again.', 'warning')
return redirect(url_for('plaid.index'))
raw = json.loads(sp.data_json)
next_cursor = sp.next_cursor
item = db.get_or_404(PlaidItem, item_db_id)
db.session.delete(sp)
selected_ids = set(request.form.getlist('selected'))
from datetime import date as date_cls
parsed = []
for r in raw:
if selected_ids and r['plaid_id'] not in selected_ids:
continue
r['date'] = date_cls.fromisoformat(r['date'])
# Type override
override = request.form.get(f'type_{r["plaid_id"]}')
if override in ('income', 'expense'):
r['transaction_type'] = override
# Category override
cat_override = request.form.get(f'category_{r["plaid_id"]}', '')
if cat_override.isdigit():
r['category_id'] = int(cat_override)
parsed.append(r)
if not parsed:
flash('No transactions selected. Nothing was imported.', 'warning')
db.session.commit()
return redirect(url_for('plaid.index'))
imported, skipped = import_transactions(parsed, next_cursor, item)
flash(f'Imported {imported} transaction(s) from {item.institution_name}. '
f'Skipped {skipped} duplicate(s).', 'success')
return redirect(url_for('transactions.index'))
# ── Balance Refresh (AJAX) ────────────────────────────────────────────────────
@plaid_bp.route('/balance/<int:pa_db_id>', methods=['POST'])
@login_required
def refresh_balance(pa_db_id):
pa = db.get_or_404(PlaidAccount, pa_db_id)
if not pa.pfm_account_id:
return jsonify({'error': 'Account not mapped'}), 400
try:
bal_map = get_balances(pa.item, [pa.plaid_account_id])
bal = bal_map.get(pa.plaid_account_id, {})
current = bal.get('current')
available = bal.get('available')
if current is None and available is None:
return jsonify({'error': 'No balance data returned'}), 502
is_cc = pa.account_type == 'credit' or pa.account_subtype == 'credit card'
if is_cc:
balance = -abs(float(current))
else:
balance = float(available if available is not None else current)
pa.pfm_account.balance = balance
db.session.commit()
log.info('[plaid] balance refreshed for %s: %.2f', pa.account_name, balance)
return jsonify({'status': 'ok', 'balance': balance, 'account': pa.account_name})
except Exception as e:
log.error('[plaid] balance refresh failed for pa_id=%s: %s', pa_db_id, e, exc_info=True)
return jsonify({'error': str(e)}), 502
# ── Liabilities Refresh ───────────────────────────────────────────────────────
@plaid_bp.route('/liabilities/<int:item_db_id>', methods=['POST'])
@login_required
def refresh_liabilities_view(item_db_id):
item = db.get_or_404(PlaidItem, item_db_id)
try:
updated = refresh_liabilities(item)
if updated:
flash(f'Credit card billing details updated for {updated} account(s).', 'success')
else:
flash('No credit card accounts found, or liabilities not supported by this institution.', 'info')
except Exception as e:
log.error('[plaid] refresh_liabilities failed for item id=%s: %s', item_db_id, e, exc_info=True)
flash(f'Failed to fetch billing details: {e}', 'danger')
next_url = request.form.get('next', '')
if next_url and next_url.startswith('/'):
return redirect(next_url)
return redirect(url_for('plaid.index'))
# ── Disconnect ────────────────────────────────────────────────────────────────
@plaid_bp.route('/disconnect/<int:item_db_id>', methods=['POST'])
@login_required
def disconnect(item_db_id):
item = db.get_or_404(PlaidItem, item_db_id)
item.is_active = False
for pa in item.accounts:
pa.is_active = False
db.session.commit()
flash(f'Disconnected from {item.institution_name}. Imported transactions are kept.', 'info')
return redirect(url_for('plaid.index'))
+382
View File
@@ -0,0 +1,382 @@
"""
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',
'development': 'https://development.plaid.com',
'production': '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
+27 -11
View File
@@ -175,23 +175,32 @@ def get_transactions(access_token, account_id, start_date=None, end_date=None,
return data
def parse_transaction(teller_txn, pfm_account_id, category_id_map):
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 amounts:
- Positive = money entering the account (income / credit)
- Negative = money leaving the account (expense / debit)
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))
# Teller: positive = inflow/credit (income), negative = outflow/debit (expense)
if amount_raw < 0:
txn_type = 'expense'
amount = abs(amount_raw)
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:
txn_type = 'income'
amount = amount_raw
# 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
@@ -264,9 +273,16 @@ def sync_preview(teller_account, days_back=90):
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)
p = parse_transaction(txn, teller_account.pfm_account_id, cat_map,
is_credit_card=is_cc)
parsed.append(p)
return parsed
+112
View File
@@ -27,6 +27,7 @@
{% for acct in accounts %}
{% set ta = teller_map.get(acct.id) %}
{% set sa = schwab_map.get(acct.id) %}
{% set pa = plaid_map.get(acct.id) %}
<div class="col-12 col-md-6 col-xl-4">
<div class="pcard" style="border-left: 4px solid {{ acct.color }};">
@@ -45,6 +46,9 @@
{% if sa %}
<span style="font-size:10px;background:#d1fae5;color:#065f46;border-radius:4px;padding:1px 5px;margin-left:4px;vertical-align:middle;">Schwab</span>
{% endif %}
{% if pa %}
<span style="font-size:10px;background:#ede9fe;color:#5b21b6;border-radius:4px;padding:1px 5px;margin-left:4px;vertical-align:middle;">Plaid</span>
{% endif %}
</div>
<div style="font-size:11px;color:var(--muted);">{{ acct.account_type | replace('_',' ') | title }}</div>
</div>
@@ -90,6 +94,35 @@
</div>
{% endif %}
<!-- Plaid credit card billing details -->
{% if pa and tab == 'credit' and (pa.cc_due_date or pa.cc_minimum_payment) %}
<div class="d-flex gap-2 mt-3">
{% if pa.cc_due_date %}
{% set days_left = (pa.cc_due_date - today).days %}
<div style="flex:1;background:#f5f3ff;border-radius:8px;padding:10px 12px;">
<div style="font-size:10px;color:#6d28d9;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px;">Due Date</div>
<div style="font-size:16px;font-weight:700;color:#0f172a;">{{ pa.cc_due_date.strftime('%b %d') }}</div>
{% if days_left <= 0 %}
<div style="font-size:10px;color:#ef4444;font-weight:600;">Overdue!</div>
{% elif days_left <= 3 %}
<div style="font-size:10px;color:#f59e0b;">{{ days_left }} day{{ 's' if days_left != 1 }} left</div>
{% else %}
<div style="font-size:10px;color:var(--muted);">{{ days_left }} days left</div>
{% endif %}
</div>
{% endif %}
{% if pa.cc_minimum_payment %}
<div style="flex:1;background:#f5f3ff;border-radius:8px;padding:10px 12px;">
<div style="font-size:10px;color:#6d28d9;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px;">Min Payment</div>
<div style="font-size:16px;font-weight:700;color:#0f172a;">{{ pa.cc_minimum_payment | currency }}</div>
{% if pa.cc_last_statement_balance %}
<div style="font-size:10px;color:var(--muted);">Stmt: {{ pa.cc_last_statement_balance | currency }}</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% if acct.notes %}
<div style="font-size:12px;color:var(--muted);margin-top:10px;">{{ acct.notes }}</div>
{% endif %}
@@ -154,6 +187,37 @@
{% endif %}
{% endif %}
<!-- Plaid action buttons -->
{% if pa %}
<div class="d-flex gap-2 mt-2 pt-2" style="border-top:1px solid var(--border);">
<button onclick="refreshPlaidBalance({{ pa.id }}, {{ acct.id }}, '{{ pa.account_type }}', this)"
class="btn btn-sm btn-outline-primary flex-fill" style="font-size:11px;"
title="Pull live balance from Plaid">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
<a href="{{ url_for('plaid.sync_preview_view', item_db_id=pa.item_id) }}"
class="btn btn-sm btn-outline-primary flex-fill" style="font-size:11px;"
title="Sync new transactions from Plaid">
<i class="bi bi-cloud-download me-1"></i>Sync
</a>
{% if pa.account_type == 'credit' %}
<form method="POST" action="{{ url_for('plaid.refresh_liabilities_view', item_db_id=pa.item_id) }}" style="flex:1;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('accounts.index', tab='credit') }}">
<button type="submit" class="btn btn-sm btn-outline-secondary w-100" style="font-size:11px;"
title="Refresh due date and minimum payment from Plaid">
<i class="bi bi-credit-card me-1"></i>Billing
</button>
</form>
{% endif %}
</div>
{% if pa.last_sync_date %}
<div style="font-size:10px;color:var(--muted);margin-top:4px;text-align:right;">
Last synced {{ pa.last_sync_date.strftime('%b %d') }}
</div>
{% endif %}
{% endif %}
<!-- Schwab action buttons -->
{% if sa %}
<div class="d-flex gap-2 mt-2 pt-2" style="border-top:1px solid var(--border);">
@@ -206,6 +270,54 @@
<script>
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
function refreshPlaidBalance(paId, acctId, acctType, btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>…';
fetch('/plaid/balance/' + paId, {
method: 'POST',
headers: { 'X-CSRFToken': CSRF },
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Refresh';
if (data.status === 'ok') {
btn.style.color = '#10b981';
btn.title = 'Balance updated just now';
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from Plaid'; }, 3000);
// Update displayed balance element
const sym = '{{ current_user.currency_symbol }}';
const fmt = v => {
const n = parseFloat(v);
const abs = Math.abs(n).toLocaleString(undefined, {minimumFractionDigits:2,maximumFractionDigits:2});
return (n < 0 ? '-' : '') + sym + abs;
};
const balEl = document.getElementById('acct-bal-' + acctId);
if (balEl && data.balance != null) {
const isCc = acctType === 'credit';
if (isCc) {
const owed = Math.max(0, -parseFloat(data.balance));
balEl.textContent = (owed > 0 ? '-' : '') + fmt(owed);
} else {
balEl.textContent = fmt(data.balance);
}
balEl.style.color = '#10b981';
setTimeout(() => balEl.style.color = '', 2500);
}
} else {
btn.style.color = '#ef4444';
btn.title = data.error || 'Refresh failed';
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from Plaid'; }, 4000);
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Refresh';
btn.style.color = '#ef4444';
setTimeout(() => { btn.style.color = ''; }, 4000);
});
}
function refreshTellerBalance(taId, acctId, isCreditCard, btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>…';
+1 -1
View File
@@ -305,7 +305,7 @@
</a>
<a
href="{{ url_for('settings.index') }}"
class="sb-link {% if request.blueprint in ('settings', 'logs') %}active{% endif %}"
class="sb-link {% if request.blueprint in ('settings', 'logs', 'plaid', 'teller', 'schwab') %}active{% endif %}"
>
<i class="bi bi-gear"></i><span class="lt">Settings</span>
</a>
+250
View File
@@ -0,0 +1,250 @@
{% extends "base.html" %}
{% block title %}Plaid Bank Sync{% endblock %}
{% block page_title %}Plaid Bank Sync{% endblock %}
{% block content %}
{% if not plaid_configured %}
<div class="alert alert-warning" style="font-size:13px;">
<i class="bi bi-exclamation-triangle me-2"></i>
Plaid is not configured. Add <code>PLAID_CLIENT_ID</code>, <code>PLAID_SECRET</code>, and
<code>PLAID_ENV</code> to your <code>.env</code> file and restart the app.
</div>
{% endif %}
<!-- Connect card -->
<div class="pcard mb-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<div style="width:32px;height:32px;border-radius:8px;background:#f5f3ff;display:flex;align-items:center;justify-content:center;">
<i class="bi bi-bank2" style="color:#7c3aed;font-size:16px;"></i>
</div>
<span style="font-weight:600;font-size:15px;">Connect a Bank Account</span>
</div>
<div style="font-size:12px;color:var(--muted);">
Plaid supports 12,000+ US financial institutions — checking, savings, credit cards, and investments.
Trial accounts work in sandbox and development environments.
</div>
</div>
<button id="plaid-link-btn" class="btn btn-primary"
style="font-size:13px;min-width:150px;"
{% if not plaid_configured %}disabled{% endif %}>
<i class="bi bi-plus-lg me-1"></i>Connect Bank
</button>
</div>
</div>
{% if items %}
{% for item in items %}
<div class="pcard mb-3">
<!-- Item header -->
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<div style="font-size:15px;font-weight:600;">{{ item.institution_name }}</div>
<div style="font-size:11px;color:var(--muted);">
{{ item.accounts.filter_by(is_active=True).count() }} account(s) connected
{% if item.last_synced_at %}· Last synced {{ item.last_synced_at.strftime('%b %d, %Y %H:%M') }}{% endif %}
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="{{ url_for('plaid.sync_preview_view', item_db_id=item.id) }}"
class="btn btn-sm btn-outline-primary" style="font-size:12px;">
<i class="bi bi-cloud-download me-1"></i>Sync Transactions
</a>
<form method="POST" action="{{ url_for('plaid.refresh_liabilities_view', item_db_id=item.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('plaid.index') }}">
<button type="submit" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"
title="Refresh credit card due dates and minimum payments">
<i class="bi bi-credit-card me-1"></i>Billing Details
</button>
</form>
<form method="POST" action="{{ url_for('plaid.disconnect', item_db_id=item.id) }}"
onsubmit="return confirm('Disconnect {{ item.institution_name }}?\nImported transactions will be kept.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:12px;">
<i class="bi bi-x-lg me-1"></i>Disconnect
</button>
</form>
</div>
</div>
<!-- Accounts table -->
{% set accts = item.accounts.filter_by(is_active=True).all() %}
{% if accts %}
<div style="overflow-x:auto;">
<table class="pfm-table mb-0">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th>Mapped to</th>
<th>Last Sync</th>
<th>Credit Card Billing</th>
<th style="text-align:right;padding-right:12px;">Actions</th>
</tr>
</thead>
<tbody>
{% for pa in accts %}
<tr>
<td>
<div style="font-size:13px;font-weight:500;">{{ pa.display_name }}</div>
<div style="font-size:11px;color:var(--muted);">{{ pa.account_type | title }} · {{ pa.account_subtype | replace('_',' ') | title }}</div>
</td>
<td>
<span class="badge" style="font-size:10px;
{% if pa.account_type == 'credit' %}background:#fee2e2;color:#991b1b;
{% elif pa.account_type == 'depository' %}background:#dbeafe;color:#1e40af;
{% else %}background:#f1f5f9;color:#475569;{% endif %}">
{{ pa.account_type | title }}
</span>
</td>
<td style="font-size:12px;">
{% if pa.pfm_account %}
<span style="color:#10b981;font-weight:500;">{{ pa.pfm_account.name }}</span>
{% else %}
<a href="{{ url_for('plaid.map_accounts', item_db_id=item.id) }}"
style="font-size:11px;color:#f59e0b;">Map Account</a>
{% endif %}
</td>
<td style="font-size:11px;color:var(--muted);">
{{ pa.last_sync_date.strftime('%b %d') if pa.last_sync_date else '—' }}
</td>
<td style="font-size:11px;">
{% if pa.account_type == 'credit' %}
{% if pa.cc_due_date %}
{% set days_left = (pa.cc_due_date - today).days %}
<div>
<span style="color:#6d28d9;font-weight:600;">Due {{ pa.cc_due_date.strftime('%b %d') }}</span>
{% if days_left <= 0 %}
<span style="color:#ef4444;font-size:10px;"> · Overdue!</span>
{% elif days_left <= 3 %}
<span style="color:#f59e0b;font-size:10px;"> · {{ days_left }}d left</span>
{% else %}
<span style="color:var(--muted);font-size:10px;"> · {{ days_left }}d</span>
{% endif %}
</div>
{% if pa.cc_minimum_payment %}
<div style="color:var(--muted);">Min: {{ pa.cc_minimum_payment | currency }}</div>
{% endif %}
{% else %}
<span style="color:var(--muted);"></span>
{% endif %}
{% else %}
<span style="color:var(--muted);">N/A</span>
{% endif %}
</td>
<td style="text-align:right;padding-right:12px;">
{% if pa.pfm_account_id %}
<button onclick="refreshPlaidBalance({{ pa.id }}, {{ pa.pfm_account_id }}, '{{ pa.account_type }}', this)"
class="btn btn-sm btn-outline-secondary" style="font-size:11px;">
<i class="bi bi-arrow-clockwise me-1"></i>Balance
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="pcard text-center py-5 text-muted">
<i class="bi bi-bank" style="font-size:3rem;opacity:.25;display:block;margin-bottom:16px;"></i>
<div style="font-size:14px;font-weight:500;margin-bottom:4px;">No banks connected yet</div>
<div style="font-size:12px;">Click "Connect Bank" to link your first account via Plaid.</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
<script>
(function () {
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
const linkBtn = document.getElementById('plaid-link-btn');
linkBtn && linkBtn.addEventListener('click', async () => {
linkBtn.disabled = true;
linkBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Loading…';
try {
const r = await fetch('{{ url_for("plaid.create_link_token_view") }}',
{ method: 'POST', headers: { 'X-CSRFToken': CSRF } });
const data = await r.json();
if (!r.ok || data.error) throw new Error(data.error || 'Failed to create link token');
const handler = Plaid.create({
token: data.link_token,
onSuccess: async (publicToken, metadata) => {
linkBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Connecting…';
const resp = await fetch('{{ url_for("plaid.exchange_token") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
body: JSON.stringify({
public_token: publicToken,
institution_id: metadata.institution?.institution_id || '',
institution_name: metadata.institution?.name || 'Unknown Bank',
accounts: metadata.accounts || [],
}),
});
const result = await resp.json();
if (result.error) {
alert('Error: ' + result.error);
resetBtn();
} else {
window.location.href = result.redirect;
}
},
onExit: (err) => {
if (err) console.error('[Plaid] Link exit with error:', err);
resetBtn();
},
});
handler.open();
} catch (err) {
alert('Could not open Plaid Link: ' + err.message);
resetBtn();
}
function resetBtn() {
linkBtn.disabled = false;
linkBtn.innerHTML = '<i class="bi bi-plus-lg me-1"></i>Connect Bank';
}
});
// Balance refresh (AJAX)
window.refreshPlaidBalance = function (paId, acctId, acctType, btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
fetch('/plaid/balance/' + paId, { method: 'POST', headers: { 'X-CSRFToken': CSRF } })
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Balance';
if (data.error) {
btn.title = data.error;
btn.style.color = '#ef4444';
setTimeout(() => { btn.style.color = ''; btn.title = ''; }, 4000);
} else {
btn.style.color = '#10b981';
btn.title = 'Updated just now';
setTimeout(() => { btn.style.color = ''; btn.title = ''; }, 3000);
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Balance';
btn.style.color = '#ef4444';
setTimeout(() => { btn.style.color = ''; }, 4000);
});
};
})();
</script>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Map Plaid Accounts{% endblock %}
{% block page_title %}Map Accounts — {{ item.institution_name }}{% endblock %}
{% block content %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="pcard mb-3">
<div class="pcard-title mb-1">{{ item.institution_name }}</div>
<div style="font-size:12px;color:var(--muted);">
Map each Plaid account to a PFM account, or create a new one automatically.
Accounts left unmapped will be skipped during sync.
</div>
</div>
{% for pa in plaid_accounts %}
<div class="pcard mb-3">
<div class="d-flex align-items-start gap-3 flex-wrap">
<div style="flex:1;min-width:180px;">
<div style="font-size:14px;font-weight:600;">{{ pa.display_name }}</div>
<div style="font-size:11px;color:var(--muted);">
{{ pa.account_type | title }} · {{ pa.account_subtype | replace('_',' ') | title }}
</div>
</div>
<div style="flex:2;min-width:240px;">
<label style="font-size:12px;color:var(--muted);display:block;margin-bottom:4px;">Map to PFM Account</label>
<select name="pfm_account_{{ pa.id }}" class="form-select form-select-sm">
<option value="">— Skip this account —</option>
<option value="new">✦ Create new PFM account</option>
<optgroup label="Existing Accounts">
{% for a in pfm_accounts %}
<option value="{{ a.id }}"
{% if pa.pfm_account_id == a.id %}selected{% endif %}>
{{ a.name }} ({{ a.account_type | replace('_',' ') | title }})
</option>
{% endfor %}
</optgroup>
</select>
</div>
</div>
</div>
{% endfor %}
<div class="d-flex gap-2 justify-content-end">
<a href="{{ url_for('plaid.index') }}" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Mapping
</button>
</div>
</form>
{% endblock %}
+201
View File
@@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}Plaid Sync Preview{% endblock %}
{% block page_title %}Sync Preview — {{ item.institution_name }}{% endblock %}
{% block content %}
<form method="POST" action="{{ url_for('plaid.sync_confirm') }}" id="importForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="pcard mb-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
<div style="font-size:14px;font-weight:600;">{{ item.institution_name }}</div>
<div style="font-size:12px;color:var(--muted);">
<span id="selectedCount">{{ count }}</span> of {{ count }} transactions selected
</div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('plaid.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">Cancel</a>
<button type="submit" class="btn btn-sm btn-success" id="importBtn" style="font-size:12px;">
<i class="bi bi-check-lg me-1"></i>Import <span id="importBtnCount">{{ count }}</span> Transaction(s)
</button>
</div>
</div>
<!-- Bulk actions -->
<div class="mt-3 pt-3 d-flex align-items-center flex-wrap gap-2" style="border-top:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Bulk set selected:</span>
<button type="button" class="btn btn-sm btn-outline-danger" id="bulkExpense" style="font-size:12px;">
<i class="bi bi-dash-circle me-1"></i>Expense
</button>
<button type="button" class="btn btn-sm btn-outline-success" id="bulkIncome" style="font-size:12px;">
<i class="bi bi-plus-circle me-1"></i>Income
</button>
<div class="d-flex align-items-center gap-1 ms-1">
<select id="bulkCategory" class="form-select form-select-sm" style="min-width:150px;font-size:12px;">
<option value="">— Category —</option>
{% for c in categories %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulkApplyCat" style="font-size:12px;">Apply</button>
</div>
</div>
</div>
{% set ns = namespace(cur_acct_id=None) %}
{% for txn in preview %}
{% set pa = plaid_accounts.get(txn.plaid_account_id) %}
{% set acct_id = pa.pfm_account_id if pa else None %}
{% if acct_id != ns.cur_acct_id %}
{% if ns.cur_acct_id is not none %}</div></div>{% endif %}
{% set ns.cur_acct_id = acct_id %}
<div class="pcard p-0 mb-3">
<div class="px-4 py-2" style="background:#f8fafc;border-bottom:1px solid var(--border);border-radius:12px 12px 0 0;">
<div style="font-size:13px;font-weight:600;">
{% if pa and pa.pfm_account %}{{ pa.pfm_account.name }}{% else %}Unknown Account{% endif %}
</div>
{% if pa %}
<div style="font-size:11px;color:var(--muted);">{{ pa.display_name }} · {{ pa.account_type | title }}</div>
{% endif %}
</div>
<div>
<table class="pfm-table mb-0">
<thead>
<tr>
<th style="width:36px;padding-left:16px;">
<input type="checkbox" class="section-all" checked
style="width:15px;height:15px;cursor:pointer;">
</th>
<th style="white-space:nowrap;">Date</th>
<th>Description</th>
<th style="width:150px;">Type</th>
<th>Category</th>
<th class="text-end" style="padding-right:20px;">Amount</th>
</tr>
</thead>
<tbody>
{% endif %}
<tr class="preview-row" data-id="{{ txn.plaid_id }}">
<td style="padding-left:16px;">
<input type="checkbox" name="selected" value="{{ txn.plaid_id }}"
class="row-check" checked style="width:15px;height:15px;cursor:pointer;">
</td>
<td style="font-size:12px;color:var(--muted);white-space:nowrap;">
{{ txn.date.strftime('%b %d, %Y') }}
</td>
<td style="font-size:13px;">{{ txn.description }}</td>
<td>
<select name="type_{{ txn.plaid_id }}" class="type-select form-select form-select-sm"
style="width:130px;font-size:12px;border-radius:6px;
{% if txn.transaction_type == 'income' %}border-color:#10b981;color:#10b981;{% else %}border-color:#ef4444;color:#ef4444;{% endif %}">
<option value="expense" {% if txn.transaction_type == 'expense' %}selected{% endif %}>Expense</option>
<option value="income" {% if txn.transaction_type == 'income' %}selected{% endif %}>Income</option>
</select>
</td>
<td>
<select name="category_{{ txn.plaid_id }}" class="cat-select form-select form-select-sm"
style="font-size:12px;min-width:140px;">
<option value="">— Uncategorised —</option>
{% for c in categories %}
<option value="{{ c.id }}" {% if txn.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</td>
<td class="text-end mono amount-cell
{% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
style="font-size:13px;font-weight:600;padding-right:20px;">
<span class="sign">{% if txn.transaction_type == 'income' %}+{% else %}-{% endif %}</span>{{ txn.amount | currency }}
</td>
</tr>
{% endfor %}
{% if ns.cur_acct_id is not none %}</tbody></table></div></div>{% endif %}
<div class="d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i>Confirm Import (<span id="importBtnCount2">{{ count }}</span> transactions)
</button>
</div>
</form>
<script>
(function () {
const checks = () => document.querySelectorAll('.row-check');
const countEl = document.getElementById('selectedCount');
const btnCount = document.getElementById('importBtnCount');
const btnCount2 = document.getElementById('importBtnCount2');
function updateCount() {
const n = document.querySelectorAll('.row-check:checked').length;
countEl.textContent = n;
btnCount.textContent = n;
btnCount2.textContent = n;
document.querySelectorAll('.preview-row').forEach(row => {
row.style.opacity = row.querySelector('.row-check').checked ? '1' : '0.4';
});
// Sync per-section checkboxes
document.querySelectorAll('.section-all').forEach(sa => {
const tbody = sa.closest('table').querySelector('tbody');
const rowChk = tbody.querySelectorAll('.row-check');
const chkd = tbody.querySelectorAll('.row-check:checked').length;
sa.indeterminate = chkd > 0 && chkd < rowChk.length;
sa.checked = chkd === rowChk.length;
});
}
// Section select-all
document.querySelectorAll('.section-all').forEach(sa => {
sa.addEventListener('change', () => {
const tbody = sa.closest('table').querySelector('tbody');
tbody.querySelectorAll('.row-check').forEach(cb => { cb.checked = sa.checked; });
updateCount();
});
});
document.addEventListener('change', e => {
if (e.target.classList.contains('row-check')) updateCount();
});
function applyTypeStyle(sel) {
const isIncome = sel.value === 'income';
sel.style.borderColor = isIncome ? '#10b981' : '#ef4444';
sel.style.color = isIncome ? '#10b981' : '#ef4444';
const row = sel.closest('.preview-row');
const cell = row.querySelector('.amount-cell');
const sign = row.querySelector('.sign');
cell.classList.toggle('text-income', isIncome);
cell.classList.toggle('text-expense', !isIncome);
sign.textContent = isIncome ? '+' : '-';
}
document.querySelectorAll('.type-select').forEach(sel => {
sel.addEventListener('change', function () { applyTypeStyle(this); });
});
// Bulk: type
document.getElementById('bulkExpense').addEventListener('click', () => {
document.querySelectorAll('.row-check:checked').forEach(cb => {
const sel = cb.closest('.preview-row').querySelector('.type-select');
sel.value = 'expense'; applyTypeStyle(sel);
});
});
document.getElementById('bulkIncome').addEventListener('click', () => {
document.querySelectorAll('.row-check:checked').forEach(cb => {
const sel = cb.closest('.preview-row').querySelector('.type-select');
sel.value = 'income'; applyTypeStyle(sel);
});
});
// Bulk: category
document.getElementById('bulkApplyCat').addEventListener('click', () => {
const catId = document.getElementById('bulkCategory').value;
if (!catId) return;
document.querySelectorAll('.row-check:checked').forEach(cb => {
const sel = cb.closest('.preview-row').querySelector('.cat-select');
if (sel) sel.value = catId;
});
document.getElementById('bulkCategory').value = '';
});
})();
</script>
{% endblock %}
+9
View File
@@ -22,6 +22,15 @@
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Connect Charles Schwab directly</div>
</div>
</a>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('plaid.index') }}" class="text-decoration-none">
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#7c3aed'" onmouseout="this.style.borderColor='var(--border)'">
<i class="bi bi-bank2" style="font-size:2rem;color:#7c3aed;"></i>
<div style="font-size:14px;font-weight:600;margin-top:10px;">Plaid Sync</div>
<div style="font-size:12px;color:var(--muted);margin-top:4px;">12,000+ US banks · CC billing dates</div>
</div>
</a>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('settings.profile') }}" class="text-decoration-none">
+94
View File
@@ -0,0 +1,94 @@
"""
Migration: add Plaid tables.
Run once after deploying the Plaid integration:
python scripts/add_plaid_tables.py
Creates:
plaid_items one row per connected bank (Item in Plaid terminology)
plaid_accounts one row per Plaid account, mapped to a PFM account
plaid_sync_previews temporary storage for transaction sync previews
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app
from app.extensions import db
TABLES = [
# plaid_items
"""
CREATE TABLE IF NOT EXISTS `plaid_items` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`item_id` VARCHAR(100) NOT NULL UNIQUE,
`access_token` TEXT NOT NULL,
`institution_id` VARCHAR(50) DEFAULT NULL,
`institution_name` VARCHAR(100) DEFAULT NULL,
`cursor` VARCHAR(500) DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_synced_at` DATETIME DEFAULT NULL,
INDEX `ix_plaid_items_item_id` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# plaid_accounts
"""
CREATE TABLE IF NOT EXISTS `plaid_accounts` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`item_id` INT NOT NULL,
`plaid_account_id` VARCHAR(100) NOT NULL UNIQUE,
`pfm_account_id` INT DEFAULT NULL,
`account_name` VARCHAR(100) DEFAULT NULL,
`account_type` VARCHAR(50) DEFAULT NULL,
`account_subtype` VARCHAR(50) DEFAULT NULL,
`mask` VARCHAR(10) DEFAULT NULL,
`last_sync_date` DATE DEFAULT NULL,
`cc_due_date` DATE DEFAULT NULL,
`cc_minimum_payment` DECIMAL(12,2) DEFAULT NULL,
`cc_last_statement_balance` DECIMAL(12,2) DEFAULT NULL,
`cc_is_overdue` TINYINT(1) NOT NULL DEFAULT 0,
`cc_updated_at` DATETIME DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`item_id`) REFERENCES `plaid_items`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`pfm_account_id`) REFERENCES `accounts`(`id`) ON DELETE SET NULL,
INDEX `ix_plaid_accounts_plaid_account_id` (`plaid_account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# plaid_sync_previews
"""
CREATE TABLE IF NOT EXISTS `plaid_sync_previews` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`item_id` INT NOT NULL UNIQUE,
`data_json` TEXT NOT NULL,
`next_cursor` VARCHAR(500) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`item_id`) REFERENCES `plaid_items`(`id`) ON DELETE CASCADE,
INDEX `ix_plaid_sync_previews_item_id` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
def run():
app = create_app()
with app.app_context():
conn = db.engine.raw_connection()
cur = conn.cursor()
for ddl in TABLES:
name = ddl.strip().split('`')[1]
cur.execute(ddl)
print(f'{name}')
conn.commit()
cur.close()
conn.close()
print('Done.')
if __name__ == '__main__':
run()