Files
sure/app/controllers/plaid_items_controller.rb
Rene Arredondo be2d3aa3bb fix(plaid): surface configuration/product-access errors from the Link flow (#1792) (#1991)
* fix(plaid): surface configuration/product-access errors from Link flow (#1792)

* fix(plaid): harden Plaid Link onExit guard + nil-body JSON parse (#1792 review)

* fix lint check issue

* fix test unit check
2026-05-28 14:55:21 +02:00

177 lines
5.6 KiB
Ruby

class PlaidItemsController < ApplicationController
include StreamExtensions
before_action :set_plaid_item, only: %i[edit destroy sync]
before_action :require_admin!, only: %i[new create select_existing_account link_existing_account 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
)
rescue Plaid::ApiError => e
handle_link_token_error(e)
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,
)
rescue Plaid::ApiError => e
handle_link_token_error(e)
end
def create
Current.family.create_plaid_item!(
public_token: plaid_item_params[:public_token],
item_name: item_name,
region: plaid_item_params[:region]
)
redirect_to accounts_path, notice: t(".success")
end
def destroy
@plaid_item.destroy_later
redirect_to accounts_path, notice: t(".success")
end
def sync
unless @plaid_item.syncing?
@plaid_item.sync_later
end
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
def select_existing_account
@account = Current.family.accounts.find(params[:account_id])
@region = params[:region] || "us"
# Get all Plaid accounts from this family's Plaid items for the specified region
# that are not yet linked to any account
@available_plaid_accounts = Current.family.plaid_items
.where(plaid_region: @region)
.includes(:plaid_accounts)
.flat_map(&:plaid_accounts)
.select { |pa| pa.account_provider.nil? && pa.account.nil? } # Not linked via new or legacy system
if @available_plaid_accounts.empty?
redirect_to account_path(@account), alert: t(".no_available_accounts")
end
end
def link_existing_account
@account = Current.family.accounts.find(params[:account_id])
plaid_account = PlaidAccount.find(params[:plaid_account_id])
# Verify the Plaid account belongs to this family's Plaid items
unless Current.family.plaid_items.include?(plaid_account.plaid_item)
redirect_to account_path(@account), alert: t(".invalid_account")
return
end
# Verify the Plaid account is not already linked
if plaid_account.account_provider.present? || plaid_account.account.present?
redirect_to account_path(@account), alert: t(".already_linked")
return
end
# Create the link via AccountProvider
AccountProvider.create!(
account: @account,
provider: plaid_account
)
redirect_to accounts_path, notice: t(".success")
end
private
def set_plaid_item
@plaid_item = Current.family.plaid_items.find(params[:id])
end
def plaid_item_params
params.require(:plaid_item).permit(:public_token, :region, metadata: {})
end
def item_name
plaid_item_params.dig(:metadata, :institution, :name)
end
# When `link_token/create` (or the update equivalent) raises, surface a
# friendly alert to the user instead of letting the modal frame render
# blank. Plaid configuration/product-access errors are the common case for
# self-hosted users — without this, the Link modal simply never opens and
# the only signal lives in server logs.
def handle_link_token_error(error)
error_body = safe_parse_plaid_error(error)
error_code = error_body["error_code"].to_s
Rails.logger.warn(
"Plaid link_token request failed: #{error_code} - #{error_body['error_message']}"
)
Sentry.capture_exception(error) if defined?(Sentry)
alert = friendly_link_token_alert(error_code, error_body["error_message"])
respond_to do |format|
format.html { redirect_to accounts_path, alert: alert }
format.turbo_stream { stream_redirect_to(accounts_path, alert: alert) }
end
end
def safe_parse_plaid_error(error)
JSON.parse(error.response_body.to_s)
rescue JSON::ParserError
{}
end
# Plaid surfaces its own actionable copy on configuration / product-access
# failures (e.g. "Your account is not enabled for the following products
# [...]. To request access, visit dashboard.plaid.com..."). Those messages
# are safe to show verbatim — they describe a Plaid-side config issue,
# not user data. For everything else we fall back to a generic message
# and rely on the log + Sentry trail.
SHOWABLE_PLAID_ERROR_CODES = %w[
INVALID_PRODUCT
PRODUCTS_NOT_SUPPORTED
NO_PRODUCTS_PERMISSION
ADDITION_LIMIT
INVALID_INSTITUTION
INSTITUTION_NOT_ENABLED_IN_REGION
INSTITUTION_NOT_SUPPORTED
].freeze
def friendly_link_token_alert(error_code, error_message)
if SHOWABLE_PLAID_ERROR_CODES.include?(error_code) && error_message.present?
t("plaid_items.errors.link_token_with_message", message: error_message)
else
t("plaid_items.errors.link_token_generic")
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