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? %> +
<%= t(".connection_lost_description") %>
+