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