From ffc5f844b20b2e2e22b3b655650fe47d064e3b72 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sat, 24 May 2025 18:33:59 -0400 Subject: [PATCH] Plaid webhook processor --- app/controllers/webhooks_controller.rb | 6 ++- app/models/plaid_item/webhook_processor.rb | 51 ++++++++++++++++++++++ app/models/provider/plaid.rb | 23 ---------- 3 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 app/models/plaid_item/webhook_processor.rb diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 1ef75db6..54cf8331 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -9,7 +9,8 @@ class WebhooksController < ApplicationController client = Provider::Registry.plaid_provider_for_region(:us) client.validate_webhook!(plaid_verification_header, webhook_body) - client.process_webhook(webhook_body) + + PlaidItem::WebhookProcessor.new(webhook_body).process render json: { received: true }, status: :ok rescue => error @@ -24,7 +25,8 @@ class WebhooksController < ApplicationController client = Provider::Registry.plaid_provider_for_region(:eu) client.validate_webhook!(plaid_verification_header, webhook_body) - client.process_webhook(webhook_body) + + PlaidItem::WebhookProcessor.new(webhook_body).process render json: { received: true }, status: :ok rescue => error diff --git a/app/models/plaid_item/webhook_processor.rb b/app/models/plaid_item/webhook_processor.rb new file mode 100644 index 00000000..4db8745f --- /dev/null +++ b/app/models/plaid_item/webhook_processor.rb @@ -0,0 +1,51 @@ +class PlaidItem::WebhookProcessor + MissingItemError = Class.new(StandardError) + + def initialize(webhook_body) + parsed = JSON.parse(webhook_body) + @webhook_type = parsed["webhook_type"] + @webhook_code = parsed["webhook_code"] + @item_id = parsed["item_id"] + end + + def process + unless plaid_item + handle_missing_item + return + end + + case [ webhook_type, webhook_code ] + when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ] + plaid_item.sync_later + when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ] + plaid_item.sync_later + when [ "HOLDINGS", "DEFAULT_UPDATE" ] + plaid_item.sync_later + else + Rails.logger.warn("Unhandled Plaid webhook type: #{webhook_type}:#{webhook_code}") + end + rescue => e + # To always ensure we return a 200 to Plaid (to keep endpoint healthy), silently capture and report all errors + Sentry.capture_exception(e) + end + + private + attr_reader :webhook_type, :webhook_code, :item_id + + def plaid_item + @plaid_item ||= PlaidItem.find_by(plaid_id: item_id) + end + + def handle_missing_item + return if plaid_item.present? + + # If we cannot find an item in our DB, that means we've reached an invalid data state where + # the Plaid Item (upstream) still exists (and is being billed), but doesn't exist internally. + # + # Since we don't have the item which has the access token, there is nothing we can do programmatically + # here, so we just need to report it to Sentry and manually handle it. + Sentry.capture_exception(MissingItemError.new("Received Plaid webhook for item no longer in our DB. Manual action required to resolve.")) do |scope| + scope.set_tags(plaid_item_id: item_id) + end + end +end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 17b286cb..44201074 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -11,29 +11,6 @@ class Provider::Plaid @region = region end - def process_webhook(webhook_body) - parsed = JSON.parse(webhook_body) - - type = parsed["webhook_type"] - code = parsed["webhook_code"] - - item = PlaidItem.find_by(plaid_id: parsed["item_id"]) - - case [ type, code ] - when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ] - item.sync_later - when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ] - item.sync_later - when [ "HOLDINGS", "DEFAULT_UPDATE" ] - item.sync_later - else - Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}") - end - rescue => error - # Processing errors shouldn't return a 400 to Plaid since they are internal, so capture silently - Sentry.capture_exception(error) - end - def validate_webhook!(verification_header, raw_body) jwks_loader = ->(options) do key_id = options[:kid]