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 %>
@@ -9,24 +9,28 @@ <%= t("accounts.new.method_selector.manual_entry") %> <% end %> - <% if us_link_token %> + <% if show_us_link %> <%# Default US-only Link %> - + <% end %> <% end %> <%# EU Link %> - <% if eu_link_token %> - + <% end %> <% end %>
<% end %> diff --git a/app/views/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb index 0f7d4fcd..f6cd7bca 100644 --- a/app/views/credit_cards/new.html.erb +++ b/app/views/credit_cards/new.html.erb @@ -1,5 +1,9 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> + <%= render "accounts/new/method_selector", + path: new_credit_card_path(return_to: params[:return_to]), + show_us_link: @show_us_link, + show_eu_link: @show_eu_link, + accountable_type: "CreditCard" %> <% else %> <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> diff --git a/app/views/cryptos/new.html.erb b/app/views/cryptos/new.html.erb index 5587cfae..a803df7b 100644 --- a/app/views/cryptos/new.html.erb +++ b/app/views/cryptos/new.html.erb @@ -1,5 +1,9 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> + <%= render "accounts/new/method_selector", + path: new_crypto_path(return_to: params[:return_to]), + show_us_link: @show_us_link, + show_eu_link: @show_eu_link, + accountable_type: "Crypto" %> <% else %> <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> diff --git a/app/views/depositories/new.html.erb b/app/views/depositories/new.html.erb index 4f08e3c9..c2110726 100644 --- a/app/views/depositories/new.html.erb +++ b/app/views/depositories/new.html.erb @@ -1,5 +1,9 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_depository_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> + <%= render "accounts/new/method_selector", + path: new_depository_path(return_to: params[:return_to]), + show_us_link: @show_us_link, + show_eu_link: @show_eu_link, + accountable_type: "Depository" %> <% else %> <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> diff --git a/app/views/investments/new.html.erb b/app/views/investments/new.html.erb index 737c5818..d31af9c9 100644 --- a/app/views/investments/new.html.erb +++ b/app/views/investments/new.html.erb @@ -1,5 +1,9 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> + <%= render "accounts/new/method_selector", + path: new_investment_path(return_to: params[:return_to]), + show_us_link: @show_us_link, + show_eu_link: @show_eu_link, + accountable_type: "Investment" %> <% else %> <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb index de4c9575..ae16df8e 100644 --- a/app/views/loans/new.html.erb +++ b/app/views/loans/new.html.erb @@ -1,5 +1,9 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> + <%= render "accounts/new/method_selector", + path: new_loan_path(return_to: params[:return_to]), + show_us_link: @show_us_link, + show_eu_link: @show_eu_link, + accountable_type: "Loan" %> <% else %> <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> diff --git a/app/views/plaid_items/_auto_link_opener.html.erb b/app/views/plaid_items/_auto_link_opener.html.erb new file mode 100644 index 00000000..0a43028b --- /dev/null +++ b/app/views/plaid_items/_auto_link_opener.html.erb @@ -0,0 +1,9 @@ +<%# locals: (link_token:, region:, item_id:, is_update: false) %> + +<%= tag.div data: { + controller: "plaid", + plaid_link_token_value: link_token, + plaid_region_value: region, + plaid_item_id_value: item_id, + plaid_is_update_value: is_update + } %> \ No newline at end of file diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index 61dea7dc..6ddb550c 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -30,7 +30,7 @@ <% elsif plaid_item.requires_update? %>
- <%= icon "alert-triangle", size: "sm" %> + <%= icon "alert-triangle", size: "sm", color: "warning" %> <%= tag.span t(".requires_update") %>
<% elsif plaid_item.sync_error.present? %> @@ -48,56 +48,18 @@
<% 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) %> - - <%= render ButtonComponent.new( - text: t(".update"), - icon: "refresh-cw", - variant: "secondary", - data: { - controller: "plaid", - action: "plaid#open", - plaid_region_value: plaid_item.plaid_region, - plaid_link_token_value: link_token, - plaid_is_update_value: true, - plaid_item_id_value: plaid_item.id - } - ) %> - <% rescue PlaidItem::PlaidConnectionLostError %> -
-
- <%= icon "alert-triangle", size: "sm", color: "warning" %> - <%= tag.span t(".connection_lost") %> -
- -

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

- -
- <%= render ButtonComponent.new( - text: t(".delete"), - icon: "trash-2", - variant: "destructive", - href: plaid_item_path(plaid_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(plaid_item.name, high_severity: true) - ) %> - - <%= render LinkComponent.new( - text: t(".add_new"), - icon: "plus", - variant: "secondary", - href: new_account_path - ) %> -
-
- <% end %> + <%= render LinkComponent.new( + text: t(".update"), + icon: "refresh-cw", + variant: "secondary", + href: edit_plaid_item_path(plaid_item), + frame: "modal" + ) %> <% elsif Rails.env.development? %> <%= icon( "refresh-cw", as_button: true, - href: sync_plaid_item_path(plaid_item), - disabled: plaid_item.syncing? || plaid_item.scheduled_for_deletion? + href: sync_plaid_item_path(plaid_item) ) %> <% end %> diff --git a/app/views/plaid_items/edit.html.erb b/app/views/plaid_items/edit.html.erb new file mode 100644 index 00000000..31620ad9 --- /dev/null +++ b/app/views/plaid_items/edit.html.erb @@ -0,0 +1,8 @@ +<%# We render this in the empty modal frame so if Plaid flow is closed, user stays on same page they were on %> +<%= turbo_frame_tag "modal" do %> + <%= render "plaid_items/auto_link_opener", + link_token: @link_token, + region: @plaid_item.plaid_region, + item_id: @plaid_item.id, + is_update: true %> +<% end %> \ No newline at end of file diff --git a/app/views/plaid_items/new.html.erb b/app/views/plaid_items/new.html.erb new file mode 100644 index 00000000..a3d83660 --- /dev/null +++ b/app/views/plaid_items/new.html.erb @@ -0,0 +1,8 @@ +<%# We render this in the empty modal frame so if Plaid flow is closed, user stays on same page they were on %> +<%= turbo_frame_tag "modal" do %> + <%= render "plaid_items/auto_link_opener", + link_token: @link_token, + region: params[:region], + item_id: "", + is_update: false %> +<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index ec9e2cce..35feaaf8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -193,7 +193,7 @@ Rails.application.routes.draw do end end - resources :plaid_items, only: %i[create destroy] do + resources :plaid_items, only: %i[new edit create destroy] do member do post :sync end