mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09: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:
parent
08a2d35308
commit
f1f2e103ce
11 changed files with 156 additions and 19 deletions
|
@ -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
|
||||||
|
|
9
app/helpers/plaid_helper.rb
Normal file
9
app/helpers/plaid_helper.rb
Normal 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
|
|
@ -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) => {
|
||||||
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", {
|
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) => {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
plaid_data = fetch_and_load_plaid_data
|
begin
|
||||||
|
plaid_data = fetch_and_load_plaid_data
|
||||||
accounts.each do |account|
|
update!(status: :good) if requires_update?
|
||||||
account.sync_later(start_date: start_date)
|
plaid_data
|
||||||
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,8 +47,53 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<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 %>
|
<% if plaid_item.requires_update? %>
|
||||||
<%= lucide_icon "refresh-cw", class: "w-4 h-4" %>
|
<% 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 %>
|
<% end %>
|
||||||
|
|
||||||
<%= contextual_menu do %>
|
<%= contextual_menu do %>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
5
db/migrate/20250212163624_add_status_to_plaid_items.rb
Normal file
5
db/migrate/20250212163624_add_status_to_plaid_items.rb
Normal 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
3
db/schema.rb
generated
|
@ -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
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue