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
+87
View File
@@ -0,0 +1,87 @@
"""
Health check endpoint — no authentication required.
Used by uptime monitors (UptimeRobot, etc.) to verify the app and subsystems.
"""
import logging
import time
from datetime import datetime
from flask import Blueprint, jsonify
from sqlalchemy import func, text
from app.extensions import db
health_bp = Blueprint('health', __name__)
log = logging.getLogger('app.health')
@health_bp.route('/health')
def health_check():
db_result = _check_db()
cron_result = _check_crons()
status = 'ok'
if db_result['status'] != 'ok':
status = 'degraded'
if any(v.get('overdue') for v in cron_result.values() if isinstance(v, dict)):
status = 'degraded'
return jsonify({
'status': status,
'timestamp': datetime.utcnow().isoformat() + 'Z',
'db': db_result,
'crons': cron_result,
}), 200 if status == 'ok' else 503
def _check_db():
t0 = time.monotonic()
try:
db.session.execute(text('SELECT 1'))
ms = round((time.monotonic() - t0) * 1000, 1)
return {'status': 'ok', 'latency_ms': ms}
except Exception as e:
log.error('[health] DB check failed: %s', e)
return {'status': 'error', 'error': str(e)}
def _check_crons():
from app.models.fx_rate import FxRate
from app.models.ai_insight import AiInsight
from app.models.net_worth_snapshot import NetWorthSnapshot
from app.models.investment import Investment
return {
# expected daily — overdue after 48 h
'fetch_fx_rate': _stat(
db.session.query(func.max(FxRate.fetched_at)).scalar(),
max_hours=48,
),
# expected daily — overdue after 48 h
'daily_ai_insight': _stat(
db.session.query(func.max(AiInsight.created_at))
.filter(AiInsight.insight_type == 'daily_summary').scalar(),
max_hours=48,
),
# expected monthly — overdue after 35 days
'daily_snapshot': _stat(
db.session.query(func.max(NetWorthSnapshot.created_at)).scalar(),
max_hours=35 * 24,
),
# expected weekdays — overdue after 4 days
'fetch_prices': _stat(
db.session.query(func.max(Investment.last_price_update)).scalar(),
max_hours=4 * 24,
),
}
def _stat(last_run_dt, max_hours):
if last_run_dt is None:
return {'last_run': None, 'age_hours': None, 'overdue': False}
age = round((datetime.utcnow() - last_run_dt).total_seconds() / 3600, 1)
return {
'last_run': last_run_dt.isoformat() + 'Z',
'age_hours': age,
'overdue': age > max_hours,
}
+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'])