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
+103 -2
View File
@@ -88,14 +88,19 @@ def _post(path, payload):
def create_link_token():
"""Create a Link token for the frontend Plaid Link widget."""
data = _post('/link/token/create', {
from flask import current_app
payload = {
'user': {'client_user_id': 'pfm-user'},
'client_name': 'Personal Finance Manager',
'products': ['transactions'],
'additional_consented_products': ['liabilities'],
'country_codes': ['US'],
'language': 'en',
})
}
webhook_url = current_app.config.get('PLAID_WEBHOOK_URL', '').strip()
if webhook_url:
payload['webhook'] = webhook_url
data = _post('/link/token/create', payload)
return data['link_token']
@@ -381,3 +386,99 @@ def import_transactions(parsed_txns, next_cursor, item):
calc_balance(acct_id)
return imported, skipped
# ── Webhook Verification ──────────────────────────────────────────────────────
def verify_webhook_token(token):
"""
Verify a Plaid webhook JWT from the Plaid-Verification header.
Plaid signs webhooks with a rotating EC key (ES256).
Raises ValueError / jwt.exceptions.* on invalid tokens.
"""
import json
import jwt as pyjwt
from datetime import timezone
# Decode header without verification to extract key ID
header = pyjwt.get_unverified_header(token)
kid = header.get('kid')
if not kid:
raise ValueError('Missing kid in Plaid webhook JWT header')
# Fetch the matching public key from Plaid
data = _post('/webhook_verification_key/get', {'key_id': kid})
jwk = data.get('key', {})
if not jwk:
raise ValueError('Plaid returned empty JWK')
pub_key = pyjwt.algorithms.ECAlgorithm.from_jwk(json.dumps(jwk))
# Verify signature and standard claims
decoded = pyjwt.decode(token, pub_key, algorithms=['ES256'])
# Reject tokens issued more than 5 minutes ago (replay protection)
import time
age = time.time() - decoded.get('iat', 0)
if age > 300:
raise ValueError(f'Plaid webhook JWT is too old ({age:.0f}s)')
return decoded
# ── Auto Sync (used by webhook handler — no preview step) ────────────────────
def auto_sync_item(item):
"""
Silently fetch and import new transactions for a Plaid item.
Called from the webhook handler — skips the preview/confirm UI flow.
Also handles removed transactions by deleting them from the DB.
Returns (imported, skipped, removed_count).
"""
from app.extensions import db
from app.models.transaction import Transaction
from app.models.plaid_item import PlaidAccount
added, modified, removed, next_cursor = sync_transactions(item)
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 + modified:
if txn.get('pending', False):
continue
p = parse_transaction(txn, plaid_account_map, cat_map)
if p['account_id'] is None:
continue
parsed.append(p)
imported, skipped = import_transactions(parsed, next_cursor, item)
# Remove transactions that Plaid says are gone (e.g. pending dropped)
removed_count = 0
if removed:
for r in removed:
tid = r.get('transaction_id', '')
txn = Transaction.query.filter(
Transaction.notes.like(f'%Plaid:{tid}%')
).first()
if txn:
db.session.delete(txn)
removed_count += 1
if removed_count:
db.session.commit()
log.info('[plaid] auto_sync removed %d transaction(s) for item %s',
removed_count, item.item_id)
return imported, skipped, removed_count
def update_item_webhook(item, webhook_url):
"""Tell Plaid to send future webhooks for this item to a new URL."""
_post('/item/webhook/update', {
'access_token': item.access_token,
'webhook': webhook_url,
})
log.info('[plaid] webhook URL updated for item %s%s', item.item_id, webhook_url)