06/05 Optimize app: Add Plaid web hook, healthcheck
This commit is contained in:
+95
-1
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user