06/05 Optimize app: Add Plaid web hook, healthcheck
This commit is contained in:
@@ -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
@@ -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