06/05 Optimize app: Add Plaid web hook, healthcheck

This commit is contained in:
2026-06-05 13:57:36 -04:00
parent 452374365c
commit 2f62417c9f
9 changed files with 449 additions and 44 deletions
+95 -1
View File
@@ -6,13 +6,14 @@ 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.extensions import db, csrf as _csrf
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,
verify_webhook_token, auto_sync_item, update_item_webhook,
)
plaid_bp = Blueprint('plaid', __name__, url_prefix='/plaid')
@@ -388,6 +389,99 @@ def full_resync(item_db_id):
return redirect(url_for('plaid.index'))
# ── Webhook Receiver ─────────────────────────────────────────────────────────
@plaid_bp.route('/webhook', methods=['POST'])
@_csrf.exempt
def webhook():
"""
Receive Plaid transaction webhooks.
Plaid signs every request with a JWT in the Plaid-Verification header (ES256).
On TRANSACTIONS events: auto-import new transactions without requiring user confirmation.
On ITEM errors: log for operator visibility.
"""
token = request.headers.get('Plaid-Verification', '')
if not token:
log.warning('[plaid] webhook received without Plaid-Verification header')
return jsonify({'error': 'Missing verification token'}), 400
try:
verify_webhook_token(token)
except Exception as e:
log.warning('[plaid] webhook JWT verification failed: %s', e)
return jsonify({'error': 'Verification failed'}), 401
payload = request.get_json(silent=True) or {}
wh_type = payload.get('webhook_type', '')
wh_code = payload.get('webhook_code', '')
item_id = payload.get('item_id', '')
log.info('[plaid] webhook %s/%s item_id=%s', wh_type, wh_code, item_id)
if wh_type == 'TRANSACTIONS' and wh_code in (
'SYNC_UPDATES_AVAILABLE', 'DEFAULT_UPDATE',
'HISTORICAL_UPDATE', 'INITIAL_UPDATE',
):
item = PlaidItem.query.filter_by(item_id=item_id, is_active=True).first()
if not item:
log.warning('[plaid] webhook: no active item for item_id=%s', item_id)
return jsonify({'ok': True}) # 200 so Plaid doesn't keep retrying
try:
imported, skipped, removed = auto_sync_item(item)
log.info('[plaid] webhook auto-sync done: +%d skipped=%d removed=%d',
imported, skipped, removed)
except Exception as e:
log.error('[plaid] webhook auto-sync failed for item_id=%s: %s',
item_id, e, exc_info=True)
# Still return 200 — error is logged; retrying won't help an app-level error
elif wh_type == 'ITEM':
error = payload.get('error') or {}
if wh_code == 'ERROR':
log.error('[plaid] ITEM/ERROR item_id=%s code=%s msg=%s',
item_id, error.get('error_code'), error.get('error_message'))
elif wh_code == 'PENDING_EXPIRATION':
log.warning('[plaid] ITEM/PENDING_EXPIRATION item_id=%s — re-auth required soon', item_id)
elif wh_code == 'USER_PERMISSION_REVOKED':
log.warning('[plaid] ITEM/USER_PERMISSION_REVOKED item_id=%s', item_id)
return jsonify({'ok': True})
# ── Update Webhook URL for Existing Items ─────────────────────────────────────
@plaid_bp.route('/update-webhook', methods=['POST'])
@login_required
def update_webhook():
"""
Tell Plaid to use the currently configured PLAID_WEBHOOK_URL for all active items.
Call this once after adding/changing PLAID_WEBHOOK_URL in .env for items that were
connected before the webhook was configured.
"""
webhook_url = current_app.config.get('PLAID_WEBHOOK_URL', '').strip()
if not webhook_url:
flash('PLAID_WEBHOOK_URL is not set in .env — nothing to update.', 'warning')
return redirect(url_for('plaid.index'))
items = PlaidItem.query.filter_by(is_active=True).all()
updated = 0
errors = 0
for item in items:
try:
update_item_webhook(item, webhook_url)
updated += 1
except Exception as e:
log.error('[plaid] update_item_webhook failed for item %s: %s', item.item_id, e)
errors += 1
if updated:
flash(f'Webhook URL updated for {updated} item(s).', 'success')
if errors:
flash(f'{errors} item(s) failed — check app logs.', 'warning')
return redirect(url_for('plaid.index'))
# ── Disconnect ────────────────────────────────────────────────────────────────
@plaid_bp.route('/disconnect/<int:item_db_id>', methods=['POST'])