diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 1b2f0aa6..ec8bc845 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -5,7 +5,7 @@ module AccountableResource include ScrollFocusable, Periodable before_action :set_account, only: [ :show, :edit, :update, :destroy ] - before_action :set_link_token, only: :new + before_action :set_link_options, only: :new end class_methods do @@ -59,34 +59,9 @@ module AccountableResource end private - def set_link_token - @us_link_token = Current.family.get_link_token( - webhooks_url: plaid_us_webhooks_url, - redirect_url: accounts_url, - accountable_type: accountable_type.name, - region: :us - ) - - if Current.family.eu? - @eu_link_token = Current.family.get_link_token( - webhooks_url: plaid_eu_webhooks_url, - redirect_url: accounts_url, - accountable_type: accountable_type.name, - region: :eu - ) - end - end - - def plaid_us_webhooks_url - return webhooks_plaid_url if Rails.env.production? - - ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid" - end - - def plaid_eu_webhooks_url - return webhooks_plaid_eu_url if Rails.env.production? - - ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu" + def set_link_options + @show_us_link = Current.family.can_connect_plaid_us? + @show_eu_link = Current.family.can_connect_plaid_eu? end def accountable_type diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 8812cf0f..8f32084f 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -1,5 +1,26 @@ class PlaidItemsController < ApplicationController - before_action :set_plaid_item, only: %i[destroy sync] + before_action :set_plaid_item, only: %i[edit destroy sync] + + def new + region = params[:region] == "eu" ? :eu : :us + webhooks_url = region == :eu ? plaid_eu_webhooks_url : plaid_us_webhooks_url + + @link_token = Current.family.get_link_token( + webhooks_url: webhooks_url, + redirect_url: accounts_url, + accountable_type: params[:accountable_type] || "Depository", + region: region + ) + end + + def edit + webhooks_url = @plaid_item.plaid_region == "eu" ? plaid_eu_webhooks_url : plaid_us_webhooks_url + + @link_token = @plaid_item.get_update_link_token( + webhooks_url: webhooks_url, + redirect_url: accounts_url, + ) + end def create Current.family.create_plaid_item!( @@ -39,4 +60,16 @@ class PlaidItemsController < ApplicationController def item_name plaid_item_params.dig(:metadata, :institution, :name) end + + def plaid_us_webhooks_url + return webhooks_plaid_url if Rails.env.production? + + ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid" + end + + def plaid_eu_webhooks_url + return webhooks_plaid_eu_url if Rails.env.production? + + ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu" + end end diff --git a/app/helpers/plaid_helper.rb b/app/helpers/plaid_helper.rb deleted file mode 100644 index 5b385839..00000000 --- a/app/helpers/plaid_helper.rb +++ /dev/null @@ -1,9 +0,0 @@ -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 c415bb2b..a12660f0 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -6,16 +6,20 @@ export default class extends Controller { linkToken: String, region: { type: String, default: "us" }, isUpdate: { type: Boolean, default: false }, - itemId: String + itemId: String, }; + connect() { + this.open(); + } + open() { const handler = Plaid.create({ token: this.linkTokenValue, onSuccess: this.handleSuccess, onLoad: this.handleLoad, onExit: this.handleExit, - onEvent: this.handleEvent + onEvent: this.handleEvent, }); handler.open(); @@ -27,10 +31,10 @@ export default class extends Controller { fetch(`/plaid_items/${this.itemIdValue}/sync`, { method: "POST", headers: { - "Accept": "application/json", + 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"; diff --git a/app/models/family/plaid_connectable.rb b/app/models/family/plaid_connectable.rb index 6eb432ba..b0f1f80d 100644 --- a/app/models/family/plaid_connectable.rb +++ b/app/models/family/plaid_connectable.rb @@ -5,6 +5,15 @@ module Family::PlaidConnectable has_many :plaid_items, dependent: :destroy end + def can_connect_plaid_us? + plaid(:us).present? + end + + # If Plaid provider is configured and user is in the EU region + def can_connect_plaid_eu? + plaid(:eu).present? && self.eu? + end + def create_plaid_item!(public_token:, item_name:, region:) public_token_response = plaid(region).exchange_public_token(public_token) @@ -34,6 +43,6 @@ module Family::PlaidConnectable private def plaid(region) - @plaid ||= Provider::Registry.plaid_provider_for_region(region) + Provider::Registry.plaid_provider_for_region(region) end end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index be022a08..151b261e 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -23,24 +23,22 @@ class PlaidItem < ApplicationRecord scope :needs_update, -> { where(status: :requires_update) } 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) + 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 + if error_body["error_code"] == "ITEM_NOT_FOUND" + # Mark the connection as invalid but don't auto-delete + update!(status: :requires_update) end + + Sentry.capture_exception(e) + nil end def destroy_later @@ -118,6 +116,4 @@ class PlaidItem < ApplicationRecord def supported_products available_products + billed_products end - - class PlaidConnectionLostError < StandardError; end end diff --git a/app/models/plaid_item/webhook_processor.rb b/app/models/plaid_item/webhook_processor.rb index 4db8745f..4d8b70c6 100644 --- a/app/models/plaid_item/webhook_processor.rb +++ b/app/models/plaid_item/webhook_processor.rb @@ -6,6 +6,7 @@ class PlaidItem::WebhookProcessor @webhook_type = parsed["webhook_type"] @webhook_code = parsed["webhook_code"] @item_id = parsed["item_id"] + @error = parsed["error"] end def process @@ -21,6 +22,10 @@ class PlaidItem::WebhookProcessor plaid_item.sync_later when [ "HOLDINGS", "DEFAULT_UPDATE" ] plaid_item.sync_later + when [ "ITEM", "ERROR" ] + if error["error_code"] == "ITEM_LOGIN_REQUIRED" + plaid_item.update!(status: :requires_update) + end else Rails.logger.warn("Unhandled Plaid webhook type: #{webhook_type}:#{webhook_code}") end @@ -30,7 +35,7 @@ class PlaidItem::WebhookProcessor end private - attr_reader :webhook_type, :webhook_code, :item_id + attr_reader :webhook_type, :webhook_code, :item_id, :error def plaid_item @plaid_item ||= PlaidItem.find_by(plaid_id: item_id) diff --git a/app/models/provider/plaid_sandbox.rb b/app/models/provider/plaid_sandbox.rb index cec6e065..52e06d2c 100644 --- a/app/models/provider/plaid_sandbox.rb +++ b/app/models/provider/plaid_sandbox.rb @@ -28,6 +28,14 @@ class Provider::PlaidSandbox < Provider::Plaid ) end + def reset_login(item) + client.sandbox_item_reset_login( + Plaid::SandboxItemResetLoginRequest.new( + access_token: item.access_token + ) + ) + end + private def create_client raise "Plaid sandbox is not supported in production" if Rails.env.production? diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 25dc9f62..e30c2463 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -1,4 +1,4 @@ -<%# locals: (path:, us_link_token: nil, eu_link_token: nil) %> +<%# locals: (path:, accountable_type:, show_us_link: true, show_eu_link: true) %> <%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
<%= t(".connection_lost_description") %>
- -