From f1f2e103cec0252562df32dbbabc917f81caa062 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 12 Feb 2025 12:59:35 -0600 Subject: [PATCH] Enhance Plaid connection management with re-authentication and error handling (#1854) * Enhance Plaid connection management with re-authentication and error handling - Add support for Plaid item status tracking (good/requires_update) - Implement re-authentication flow for Plaid connections - Handle connection errors and provide user-friendly reconnection options - Update Plaid link token generation to support item updates - Add localization for new connection management states * Remove redundant 'reconnect' localization for Plaid items --- app/controllers/plaid_items_controller.rb | 5 +- app/helpers/plaid_helper.rb | 9 ++++ .../controllers/plaid_controller.js | 26 +++++++-- app/models/family.rb | 3 +- app/models/plaid_item.rb | 44 +++++++++++++-- app/models/provider/plaid.rb | 17 ++++-- app/views/plaid_items/_plaid_item.html.erb | 54 ++++++++++++++++++- config/locales/views/plaid_items/en.yml | 5 ++ config/routes.rb | 4 +- ...0250212163624_add_status_to_plaid_items.rb | 5 ++ db/schema.rb | 3 +- 11 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 app/helpers/plaid_helper.rb create mode 100644 db/migrate/20250212163624_add_status_to_plaid_items.rb diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 64537896a0c..406da01614a 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -21,7 +21,10 @@ def sync @plaid_item.sync_later end - redirect_to accounts_path + respond_to do |format| + format.html { redirect_to accounts_path } + format.json { head :ok } + end end private diff --git a/app/helpers/plaid_helper.rb b/app/helpers/plaid_helper.rb new file mode 100644 index 00000000000..5b385839757 --- /dev/null +++ b/app/helpers/plaid_helper.rb @@ -0,0 +1,9 @@ +module PlaidHelper + def plaid_webhooks_url(region = :us) + if Rails.env.production? + region.to_sym == :eu ? webhooks_plaid_eu_url : webhooks_plaid_url + else + ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid#{region.to_sym == :eu ? '_eu' : ''}" + end + end +end diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index cf1f71cb82c..c415bb2ba6b 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -5,6 +5,8 @@ export default class extends Controller { static values = { linkToken: String, region: { type: String, default: "us" }, + isUpdate: { type: Boolean, default: false }, + itemId: String }; open() { @@ -13,15 +15,30 @@ export default class extends Controller { onSuccess: this.handleSuccess, onLoad: this.handleLoad, onExit: this.handleExit, - onEvent: this.handleEvent, + onEvent: this.handleEvent }); handler.open(); } handleSuccess = (public_token, metadata) => { - window.location.href = "/accounts"; + if (this.isUpdateValue) { + // Trigger a sync to verify the connection and update status + fetch(`/plaid_items/${this.itemIdValue}/sync`, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, + } + }).then(() => { + // Refresh the page to show the updated status + window.location.href = "/accounts"; + }); + return; + } + // For new connections, create a new Plaid item fetch("/plaid_items", { method: "POST", headers: { @@ -43,7 +60,10 @@ export default class extends Controller { }; handleExit = (err, metadata) => { - // no-op + // If there was an error during update mode, refresh the page to show latest status + if (err && metadata.status === "requires_credentials") { + window.location.href = "/accounts"; + } }; handleEvent = (eventName, metadata) => { diff --git a/app/models/family.rb b/app/models/family.rb index a0c76974db9..4f001119f62 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -62,7 +62,7 @@ def eu? country != "US" && country != "CA" end - def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us) + def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil) provider = if region.to_sym == :eu self.class.plaid_eu_provider else @@ -77,6 +77,7 @@ def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: webhooks_url: webhooks_url, redirect_url: redirect_url, accountable_type: accountable_type, + access_token: access_token ).link_token end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 370493cacb9..1a49675b249 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -2,6 +2,7 @@ class PlaidItem < ApplicationRecord include Plaidable, Syncable enum :plaid_region, { us: "us", eu: "eu" } + enum :status, { good: "good", requires_update: "requires_update" }, default: :good if Rails.application.credentials.active_record_encryption.present? encrypts :access_token, deterministic: true @@ -19,6 +20,7 @@ class PlaidItem < ApplicationRecord scope :active, -> { where(scheduled_for_deletion: false) } scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } class << self def create_from_public_token(token, item_name:, region:) @@ -38,13 +40,35 @@ def create_from_public_token(token, item_name:, region:) def sync_data(start_date: nil) update!(last_synced_at: Time.current) - plaid_data = fetch_and_load_plaid_data - - accounts.each do |account| - account.sync_later(start_date: start_date) + begin + plaid_data = fetch_and_load_plaid_data + update!(status: :good) if requires_update? + plaid_data + rescue Plaid::ApiError => e + handle_plaid_error(e) + raise e end + end - plaid_data + def get_update_link_token(webhooks_url:, redirect_url:) + begin + family.get_link_token( + webhooks_url: webhooks_url, + redirect_url: redirect_url, + region: plaid_region, + access_token: access_token + ) + rescue Plaid::ApiError => e + error_body = JSON.parse(e.response_body) + + if error_body["error_code"] == "ITEM_NOT_FOUND" + # Mark the connection as invalid but don't auto-delete + update!(status: :requires_update) + raise PlaidConnectionLostError + else + raise e + end + end end def post_sync @@ -151,4 +175,14 @@ def remove_plaid_item rescue StandardError => e Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}") end + + def handle_plaid_error(error) + error_body = JSON.parse(error.response_body) + + if error_body["error_code"] == "ITEM_LOGIN_REQUIRED" + update!(status: :requires_update) + end + end + + class PlaidConnectionLostError < StandardError; end end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 5c21fb6a1bb..8ac3b43d3e0 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -65,18 +65,25 @@ def validate_webhook!(verification_header, raw_body) raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash) end - def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil) - request = Plaid::LinkTokenCreateRequest.new({ + def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil, access_token: nil) + request_params = { user: { client_user_id: user_id }, client_name: "Maybe Finance", - products: [ get_primary_product(accountable_type) ], - additional_consented_products: get_additional_consented_products(accountable_type), country_codes: country_codes, language: "en", webhook: webhooks_url, redirect_uri: redirect_url, transactions: { days_requested: MAX_HISTORY_DAYS } - }) + } + + if access_token.present? + request_params[:access_token] = access_token + else + request_params[:products] = [ get_primary_product(accountable_type) ] + request_params[:additional_consented_products] = get_additional_consented_products(accountable_type) + end + + request = Plaid::LinkTokenCreateRequest.new(request_params) client.link_token_create(request) end diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index e8ea54fa4a4..481e0c0a686 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -28,6 +28,11 @@ <%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %> <%= tag.span t(".syncing") %> + <% elsif plaid_item.requires_update? %> +
+ <%= lucide_icon "alert-triangle", class: "w-4 h-4" %> + <%= tag.span t(".requires_update") %> +
<% elsif plaid_item.sync_error.present? %>
<%= lucide_icon "alert-circle", class: "w-4 h-4 text-red-500" %> @@ -42,8 +47,53 @@
- <%= button_to sync_plaid_item_path(plaid_item), disabled: plaid_item.syncing? || plaid_item.scheduled_for_deletion?, class: "disabled:text-gray-400 text-gray-900 flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %> - <%= lucide_icon "refresh-cw", class: "w-4 h-4" %> + <% if plaid_item.requires_update? %> + <% begin %> + <% link_token = plaid_item.get_update_link_token(webhooks_url: plaid_webhooks_url(plaid_item.plaid_region), redirect_url: accounts_url) %> + + <% rescue PlaidItem::PlaidConnectionLostError %> +
+
+ <%= lucide_icon "alert-triangle", class: "w-4 h-4" %> + <%= tag.span t(".connection_lost") %> +
+

<%= t(".connection_lost_description") %>

+
+ <%= button_to plaid_item_path(plaid_item), + method: :delete, + class: "btn btn--danger flex items-center gap-2", + data: { + turbo_confirm: { + title: t(".confirm_title"), + body: t(".confirm_body"), + accept: t(".confirm_accept") + } + } do %> + <%= lucide_icon "trash-2", class: "w-4 h-4" %> + <%= tag.span t(".delete") %> + <% end %> + <%= link_to new_account_path, class: "btn btn--secondary flex items-center gap-2" do %> + <%= lucide_icon "plus", class: "w-4 h-4" %> + <%= tag.span t(".add_new") %> + <% end %> +
+
+ <% end %> + <% else %> + <%= button_to sync_plaid_item_path(plaid_item), disabled: plaid_item.syncing? || plaid_item.scheduled_for_deletion?, class: "disabled:text-gray-400 text-gray-900 flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %> + <%= lucide_icon "refresh-cw", class: "w-4 h-4" %> + <% end %> <% end %> <%= contextual_menu do %> diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml index d4af2bf8af8..f69caafea3c 100644 --- a/config/locales/views/plaid_items/en.yml +++ b/config/locales/views/plaid_items/en.yml @@ -18,3 +18,8 @@ en: status: Last synced %{timestamp} ago status_never: Requires data sync syncing: Syncing... + requires_update: Requires re-authentication + update: Update connection + connection_lost: Connection lost + connection_lost_description: This connection is no longer valid. You'll need to delete this connection and add it again to continue syncing data. + add_new: Add new connection diff --git a/config/routes.rb b/config/routes.rb index dc04a771d68..6a7629dca5d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -178,7 +178,9 @@ end resources :plaid_items, only: %i[create destroy] do - post :sync, on: :member + member do + post :sync + end end namespace :webhooks do diff --git a/db/migrate/20250212163624_add_status_to_plaid_items.rb b/db/migrate/20250212163624_add_status_to_plaid_items.rb new file mode 100644 index 00000000000..16c23de6a95 --- /dev/null +++ b/db/migrate/20250212163624_add_status_to_plaid_items.rb @@ -0,0 +1,5 @@ +class AddStatusToPlaidItems < ActiveRecord::Migration[7.2] + def change + add_column :plaid_items, :status, :string, null: false, default: "good" + end +end diff --git a/db/schema.rb b/db/schema.rb index 9718469773d..cc0e4607918 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_02_11_161238) do +ActiveRecord::Schema[7.2].define(version: 2025_02_12_163624) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -520,6 +520,7 @@ t.string "institution_url" t.string "institution_id" t.string "institution_color" + t.string "status", default: "good", null: false t.index ["family_id"], name: "index_plaid_items_on_family_id" end