Skip to content

Commit

Permalink
Enhance Plaid connection management with re-authentication and error …
Browse files Browse the repository at this point in the history
…handling (maybe-finance#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
  • Loading branch information
Shpigford authored Feb 12, 2025
1 parent 08a2d35 commit f1f2e10
Show file tree
Hide file tree
Showing 11 changed files with 156 additions and 19 deletions.
5 changes: 4 additions & 1 deletion app/controllers/plaid_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/helpers/plaid_helper.rb
Original file line number Diff line number Diff line change
@@ -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
26 changes: 23 additions & 3 deletions app/javascript/controllers/plaid_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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: {
Expand All @@ -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) => {
Expand Down
3 changes: 2 additions & 1 deletion app/models/family.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
44 changes: 39 additions & 5 deletions app/models/plaid_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:)
Expand All @@ -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
Expand Down Expand Up @@ -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
17 changes: 12 additions & 5 deletions app/models/provider/plaid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 52 additions & 2 deletions app/views/plaid_items/_plaid_item.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
<%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif plaid_item.requires_update? %>
<div class="text-amber-500 flex items-center gap-1">
<%= lucide_icon "alert-triangle", class: "w-4 h-4" %>
<%= tag.span t(".requires_update") %>
</div>
<% elsif plaid_item.sync_error.present? %>
<div class="text-gray-500 flex items-center gap-1">
<%= lucide_icon "alert-circle", class: "w-4 h-4 text-red-500" %>
Expand All @@ -42,8 +47,53 @@
</div>

<div class="flex items-center gap-2">
<%= 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) %>
<button
data-controller="plaid"
data-action="plaid#open"
data-plaid-region-value="<%= plaid_item.plaid_region %>"
data-plaid-link-token-value="<%= link_token %>"
data-plaid-is-update-value="true"
data-plaid-item-id-value="<%= plaid_item.id %>"
class="btn btn--secondary flex items-center gap-2"
>
<%= lucide_icon "refresh-cw", class: "w-4 h-4" %>
<%= tag.span t(".update") %>
</button>
<% rescue PlaidItem::PlaidConnectionLostError %>
<div class="flex flex-col gap-2">
<div class="text-amber-500 flex items-center gap-1">
<%= lucide_icon "alert-triangle", class: "w-4 h-4" %>
<%= tag.span t(".connection_lost") %>
</div>
<p class="text-sm text-gray-500"><%= t(".connection_lost_description") %></p>
<div class="flex items-center gap-2">
<%= 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 %>
</div>
</div>
<% 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 %>
Expand Down
5 changes: 5 additions & 0 deletions config/locales/views/plaid_items/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20250212163624_add_status_to_plaid_items.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddStatusToPlaidItems < ActiveRecord::Migration[7.2]
def change
add_column :plaid_items, :status, :string, null: false, default: "good"
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit f1f2e10

Please sign in to comment.