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

Enhance Plaid connection management with re-authentication and error handling (#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
This commit is contained in:
Josh Pigford 2025-02-12 12:59:35 -06:00 committed by GitHub
parent 08a2d35308
commit f1f2e103ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 156 additions and 19 deletions

View file

@ -21,7 +21,10 @@ class PlaidItemsController < ApplicationController
@plaid_item.sync_later @plaid_item.sync_later
end end
redirect_to accounts_path respond_to do |format|
format.html { redirect_to accounts_path }
format.json { head :ok }
end
end end
private private

View file

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

View file

@ -5,6 +5,8 @@ export default class extends Controller {
static values = { static values = {
linkToken: String, linkToken: String,
region: { type: String, default: "us" }, region: { type: String, default: "us" },
isUpdate: { type: Boolean, default: false },
itemId: String
}; };
open() { open() {
@ -13,15 +15,30 @@ export default class extends Controller {
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();
} }
handleSuccess = (public_token, metadata) => { handleSuccess = (public_token, metadata) => {
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"; window.location.href = "/accounts";
});
return;
}
// For new connections, create a new Plaid item
fetch("/plaid_items", { fetch("/plaid_items", {
method: "POST", method: "POST",
headers: { headers: {
@ -43,7 +60,10 @@ export default class extends Controller {
}; };
handleExit = (err, metadata) => { 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) => { handleEvent = (eventName, metadata) => {

View file

@ -62,7 +62,7 @@ class Family < ApplicationRecord
country != "US" && country != "CA" country != "US" && country != "CA"
end 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 provider = if region.to_sym == :eu
self.class.plaid_eu_provider self.class.plaid_eu_provider
else else
@ -77,6 +77,7 @@ class Family < ApplicationRecord
webhooks_url: webhooks_url, webhooks_url: webhooks_url,
redirect_url: redirect_url, redirect_url: redirect_url,
accountable_type: accountable_type, accountable_type: accountable_type,
access_token: access_token
).link_token ).link_token
end end

View file

@ -2,6 +2,7 @@ class PlaidItem < ApplicationRecord
include Plaidable, Syncable include Plaidable, Syncable
enum :plaid_region, { us: "us", eu: "eu" } 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? if Rails.application.credentials.active_record_encryption.present?
encrypts :access_token, deterministic: true encrypts :access_token, deterministic: true
@ -19,6 +20,7 @@ class PlaidItem < ApplicationRecord
scope :active, -> { where(scheduled_for_deletion: false) } scope :active, -> { where(scheduled_for_deletion: false) }
scope :ordered, -> { order(created_at: :desc) } scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }
class << self class << self
def create_from_public_token(token, item_name:, region:) def create_from_public_token(token, item_name:, region:)
@ -38,13 +40,35 @@ class PlaidItem < ApplicationRecord
def sync_data(start_date: nil) def sync_data(start_date: nil)
update!(last_synced_at: Time.current) update!(last_synced_at: Time.current)
begin
plaid_data = fetch_and_load_plaid_data plaid_data = fetch_and_load_plaid_data
update!(status: :good) if requires_update?
accounts.each do |account| plaid_data
account.sync_later(start_date: start_date) rescue Plaid::ApiError => e
handle_plaid_error(e)
raise e
end
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 end
def post_sync def post_sync
@ -151,4 +175,14 @@ class PlaidItem < ApplicationRecord
rescue StandardError => e rescue StandardError => e
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}") Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
end 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 end

View file

@ -65,18 +65,25 @@ class Provider::Plaid
raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash) raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash)
end end
def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil) def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil, access_token: nil)
request = Plaid::LinkTokenCreateRequest.new({ request_params = {
user: { client_user_id: user_id }, user: { client_user_id: user_id },
client_name: "Maybe Finance", client_name: "Maybe Finance",
products: [ get_primary_product(accountable_type) ],
additional_consented_products: get_additional_consented_products(accountable_type),
country_codes: country_codes, country_codes: country_codes,
language: "en", language: "en",
webhook: webhooks_url, webhook: webhooks_url,
redirect_uri: redirect_url, redirect_uri: redirect_url,
transactions: { days_requested: MAX_HISTORY_DAYS } 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) client.link_token_create(request)
end end

View file

@ -28,6 +28,11 @@
<%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %> <%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %>
<%= tag.span t(".syncing") %> <%= tag.span t(".syncing") %>
</div> </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? %> <% elsif plaid_item.sync_error.present? %>
<div class="text-gray-500 flex items-center gap-1"> <div class="text-gray-500 flex items-center gap-1">
<%= lucide_icon "alert-circle", class: "w-4 h-4 text-red-500" %> <%= lucide_icon "alert-circle", class: "w-4 h-4 text-red-500" %>
@ -42,9 +47,54 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<% 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 %> <%= 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" %> <%= lucide_icon "refresh-cw", class: "w-4 h-4" %>
<% end %> <% end %>
<% end %>
<%= contextual_menu do %> <%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5"> <div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">

View file

@ -18,3 +18,8 @@ en:
status: Last synced %{timestamp} ago status: Last synced %{timestamp} ago
status_never: Requires data sync status_never: Requires data sync
syncing: Syncing... 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

View file

@ -178,7 +178,9 @@ Rails.application.routes.draw do
end end
resources :plaid_items, only: %i[create destroy] do resources :plaid_items, only: %i[create destroy] do
post :sync, on: :member member do
post :sync
end
end end
namespace :webhooks do namespace :webhooks do

View file

@ -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
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_02_11_161238) do ActiveRecord::Schema[7.2].define(version: 2025_02_12_163624) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -520,6 +520,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_11_161238) do
t.string "institution_url" t.string "institution_url"
t.string "institution_id" t.string "institution_id"
t.string "institution_color" t.string "institution_color"
t.string "status", default: "good", null: false
t.index ["family_id"], name: "index_plaid_items_on_family_id" t.index ["family_id"], name: "index_plaid_items_on_family_id"
end end