1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 21:29:38 +02:00

Lazy load Plaid link tokens, fix link issues on broadcast (#2302)

* Lazy load Plaid link tokens, fix link issues on broadcast

* Fix alert styles
This commit is contained in:
Zach Gollwitzer 2025-05-25 08:12:54 -04:00 committed by GitHub
parent c701755b02
commit d21e385962
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 155 additions and 123 deletions

View file

@ -5,7 +5,7 @@ module AccountableResource
include ScrollFocusable, Periodable include ScrollFocusable, Periodable
before_action :set_account, only: [ :show, :edit, :update, :destroy ] before_action :set_account, only: [ :show, :edit, :update, :destroy ]
before_action :set_link_token, only: :new before_action :set_link_options, only: :new
end end
class_methods do class_methods do
@ -59,34 +59,9 @@ module AccountableResource
end end
private private
def set_link_token def set_link_options
@us_link_token = Current.family.get_link_token( @show_us_link = Current.family.can_connect_plaid_us?
webhooks_url: plaid_us_webhooks_url, @show_eu_link = Current.family.can_connect_plaid_eu?
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"
end end
def accountable_type def accountable_type

View file

@ -1,5 +1,26 @@
class PlaidItemsController < ApplicationController 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 def create
Current.family.create_plaid_item!( Current.family.create_plaid_item!(
@ -39,4 +60,16 @@ class PlaidItemsController < ApplicationController
def item_name def item_name
plaid_item_params.dig(:metadata, :institution, :name) plaid_item_params.dig(:metadata, :institution, :name)
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"
end
end end

View file

@ -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

View file

@ -6,16 +6,20 @@ export default class extends Controller {
linkToken: String, linkToken: String,
region: { type: String, default: "us" }, region: { type: String, default: "us" },
isUpdate: { type: Boolean, default: false }, isUpdate: { type: Boolean, default: false },
itemId: String itemId: String,
}; };
connect() {
this.open();
}
open() { open() {
const handler = Plaid.create({ const handler = Plaid.create({
token: this.linkTokenValue, token: this.linkTokenValue,
onSuccess: this.handleSuccess, onSuccess: this.handleSuccess,
onLoad: this.handleLoad, onLoad: this.handleLoad,
onExit: this.handleExit, onExit: this.handleExit,
onEvent: this.handleEvent onEvent: this.handleEvent,
}); });
handler.open(); handler.open();
@ -27,10 +31,10 @@ export default class extends Controller {
fetch(`/plaid_items/${this.itemIdValue}/sync`, { fetch(`/plaid_items/${this.itemIdValue}/sync`, {
method: "POST", method: "POST",
headers: { headers: {
"Accept": "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
} },
}).then(() => { }).then(() => {
// Refresh the page to show the updated status // Refresh the page to show the updated status
window.location.href = "/accounts"; window.location.href = "/accounts";

View file

@ -5,6 +5,15 @@ module Family::PlaidConnectable
has_many :plaid_items, dependent: :destroy has_many :plaid_items, dependent: :destroy
end 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:) def create_plaid_item!(public_token:, item_name:, region:)
public_token_response = plaid(region).exchange_public_token(public_token) public_token_response = plaid(region).exchange_public_token(public_token)
@ -34,6 +43,6 @@ module Family::PlaidConnectable
private private
def plaid(region) def plaid(region)
@plaid ||= Provider::Registry.plaid_provider_for_region(region) Provider::Registry.plaid_provider_for_region(region)
end end
end end

View file

@ -23,7 +23,6 @@ class PlaidItem < ApplicationRecord
scope :needs_update, -> { where(status: :requires_update) } scope :needs_update, -> { where(status: :requires_update) }
def get_update_link_token(webhooks_url:, redirect_url:) def get_update_link_token(webhooks_url:, redirect_url:)
begin
family.get_link_token( family.get_link_token(
webhooks_url: webhooks_url, webhooks_url: webhooks_url,
redirect_url: redirect_url, redirect_url: redirect_url,
@ -36,11 +35,10 @@ class PlaidItem < ApplicationRecord
if error_body["error_code"] == "ITEM_NOT_FOUND" if error_body["error_code"] == "ITEM_NOT_FOUND"
# Mark the connection as invalid but don't auto-delete # Mark the connection as invalid but don't auto-delete
update!(status: :requires_update) update!(status: :requires_update)
raise PlaidConnectionLostError
else
raise e
end
end end
Sentry.capture_exception(e)
nil
end end
def destroy_later def destroy_later
@ -118,6 +116,4 @@ class PlaidItem < ApplicationRecord
def supported_products def supported_products
available_products + billed_products available_products + billed_products
end end
class PlaidConnectionLostError < StandardError; end
end end

View file

@ -6,6 +6,7 @@ class PlaidItem::WebhookProcessor
@webhook_type = parsed["webhook_type"] @webhook_type = parsed["webhook_type"]
@webhook_code = parsed["webhook_code"] @webhook_code = parsed["webhook_code"]
@item_id = parsed["item_id"] @item_id = parsed["item_id"]
@error = parsed["error"]
end end
def process def process
@ -21,6 +22,10 @@ class PlaidItem::WebhookProcessor
plaid_item.sync_later plaid_item.sync_later
when [ "HOLDINGS", "DEFAULT_UPDATE" ] when [ "HOLDINGS", "DEFAULT_UPDATE" ]
plaid_item.sync_later plaid_item.sync_later
when [ "ITEM", "ERROR" ]
if error["error_code"] == "ITEM_LOGIN_REQUIRED"
plaid_item.update!(status: :requires_update)
end
else else
Rails.logger.warn("Unhandled Plaid webhook type: #{webhook_type}:#{webhook_code}") Rails.logger.warn("Unhandled Plaid webhook type: #{webhook_type}:#{webhook_code}")
end end
@ -30,7 +35,7 @@ class PlaidItem::WebhookProcessor
end end
private private
attr_reader :webhook_type, :webhook_code, :item_id attr_reader :webhook_type, :webhook_code, :item_id, :error
def plaid_item def plaid_item
@plaid_item ||= PlaidItem.find_by(plaid_id: item_id) @plaid_item ||= PlaidItem.find_by(plaid_id: item_id)

View file

@ -28,6 +28,14 @@ class Provider::PlaidSandbox < Provider::Plaid
) )
end end
def reset_login(item)
client.sandbox_item_reset_login(
Plaid::SandboxItemResetLoginRequest.new(
access_token: item.access_token
)
)
end
private private
def create_client def create_client
raise "Plaid sandbox is not supported in production" if Rails.env.production? raise "Plaid sandbox is not supported in production" if Rails.env.production?

View file

@ -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 %> <%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
<div class="text-sm"> <div class="text-sm">
@ -9,24 +9,28 @@
<%= t("accounts.new.method_selector.manual_entry") %> <%= t("accounts.new.method_selector.manual_entry") %>
<% end %> <% end %>
<% if us_link_token %> <% if show_us_link %>
<%# Default US-only Link %> <%# Default US-only Link %>
<button data-controller="plaid" data-action="plaid#open dialog#close" data-plaid-region-value="us" data-plaid-link-token-value="<%= us_link_token %>" class="text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2"> <%= link_to new_plaid_item_path(region: "us", accountable_type: accountable_type),
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2",
data: { turbo_frame: "modal" } do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]"> <span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= icon("link-2") %> <%= icon("link-2") %>
</span> </span>
<%= t("accounts.new.method_selector.connected_entry") %> <%= t("accounts.new.method_selector.connected_entry") %>
</button> <% end %>
<% end %> <% end %>
<%# EU Link %> <%# EU Link %>
<% if eu_link_token %> <% if show_eu_link %>
<button data-controller="plaid" data-action="plaid#open dialog#close" data-plaid-region-value="eu" data-plaid-link-token-value="<%= eu_link_token %>" class="text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2"> <%= link_to new_plaid_item_path(region: "eu", accountable_type: accountable_type),
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2",
data: { turbo_frame: "modal" } do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]"> <span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= icon("link-2") %> <%= icon("link-2") %>
</span> </span>
<%= t("accounts.new.method_selector.connected_entry_eu") %> <%= t("accounts.new.method_selector.connected_entry_eu") %>
</button> <% end %>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>

View file

@ -1,5 +1,9 @@
<% if params[:step] == "method_select" %> <% 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 %> <% else %>
<%= render DialogComponent.new do |dialog| %> <%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %> <% dialog.with_header(title: t(".title")) %>

View file

@ -1,5 +1,9 @@
<% if params[:step] == "method_select" %> <% 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 %> <% else %>
<%= render DialogComponent.new do |dialog| %> <%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %> <% dialog.with_header(title: t(".title")) %>

View file

@ -1,5 +1,9 @@
<% if params[:step] == "method_select" %> <% 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 %> <% else %>
<%= render DialogComponent.new do |dialog| %> <%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %> <% dialog.with_header(title: t(".title")) %>

View file

@ -1,5 +1,9 @@
<% if params[:step] == "method_select" %> <% 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 %> <% else %>
<%= render DialogComponent.new do |dialog| %> <%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %> <% dialog.with_header(title: t(".title")) %>

View file

@ -1,5 +1,9 @@
<% if params[:step] == "method_select" %> <% 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 %> <% else %>
<%= render DialogComponent.new do |dialog| %> <%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %> <% dialog.with_header(title: t(".title")) %>

View file

@ -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
} %>

View file

@ -30,7 +30,7 @@
</div> </div>
<% elsif plaid_item.requires_update? %> <% elsif plaid_item.requires_update? %>
<div class="text-warning flex items-center gap-1"> <div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm" %> <%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span t(".requires_update") %> <%= tag.span t(".requires_update") %>
</div> </div>
<% elsif plaid_item.sync_error.present? %> <% elsif plaid_item.sync_error.present? %>
@ -48,56 +48,18 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<% if plaid_item.requires_update? %> <% if plaid_item.requires_update? %>
<% begin %> <%= render LinkComponent.new(
<% 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"), text: t(".update"),
icon: "refresh-cw", icon: "refresh-cw",
variant: "secondary", variant: "secondary",
data: { href: edit_plaid_item_path(plaid_item),
controller: "plaid", frame: "modal"
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 %>
<div class="flex flex-col gap-2">
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span t(".connection_lost") %>
</div>
<p class="text-sm text-secondary"><%= t(".connection_lost_description") %></p>
<div class="flex items-center gap-2">
<%= 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
) %>
</div>
</div>
<% end %>
<% elsif Rails.env.development? %> <% elsif Rails.env.development? %>
<%= icon( <%= icon(
"refresh-cw", "refresh-cw",
as_button: true, as_button: true,
href: sync_plaid_item_path(plaid_item), href: sync_plaid_item_path(plaid_item)
disabled: plaid_item.syncing? || plaid_item.scheduled_for_deletion?
) %> ) %>
<% end %> <% end %>

View file

@ -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 %>

View file

@ -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 %>

View file

@ -193,7 +193,7 @@ Rails.application.routes.draw do
end end
end end
resources :plaid_items, only: %i[create destroy] do resources :plaid_items, only: %i[new edit create destroy] do
member do member do
post :sync post :sync
end end