diff --git a/app/controllers/concerns/enable_banking_items/maps_helper.rb b/app/controllers/concerns/enable_banking_items/maps_helper.rb new file mode 100644 index 000000000..3293cc25d --- /dev/null +++ b/app/controllers/concerns/enable_banking_items/maps_helper.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module EnableBankingItems + module MapsHelper + extend ActiveSupport::Concern + + # Build per-item maps consumed by the enable_banking_item partial. + # Accepts a single EnableBankingItem or a collection. + def build_enable_banking_maps_for(items) + items = Array(items).compact + return if items.empty? + + @enable_banking_sync_stats_map ||= {} + @enable_banking_has_unlinked_map ||= {} + @enable_banking_unlinked_count_map ||= {} + @enable_banking_duplicate_only_map ||= {} + @enable_banking_show_relink_map ||= {} + + # Batch-check if ANY family has manual accounts (same result for all items from same family) + family_ids = items.map { |i| i.family_id }.uniq + families_with_manuals = Account + .visible_manual + .where(family_id: family_ids) + .distinct + .pluck(:family_id) + .to_set + + # Batch-fetch unlinked counts for all items in one query + unlinked_counts = EnableBankingAccount + .where(enable_banking_item_id: items.map(&:id)) + .left_joins(:account, :account_provider) + .where(accounts: { id: nil }, account_providers: { id: nil }) + .group(:enable_banking_item_id) + .count + + items.each do |item| + # Latest sync stats (avoid N+1; rely on includes(:syncs) where appropriate) + latest_sync = if item.syncs.loaded? + item.syncs.max_by(&:created_at) + else + item.syncs.ordered.first + end + stats = (latest_sync&.sync_stats || {}) + @enable_banking_sync_stats_map[item.id] = stats + + # Whether the family has any manual accounts available to link (from batch query) + @enable_banking_has_unlinked_map[item.id] = families_with_manuals.include?(item.family_id) + + # Count from batch query (defaults to 0 if not found) + @enable_banking_unlinked_count_map[item.id] = unlinked_counts[item.id] || 0 + + # Whether all reported errors for this item are duplicate-account warnings + @enable_banking_duplicate_only_map[item.id] = compute_duplicate_only_flag(stats) + + # Compute CTA visibility: show relink only when there are zero unlinked SFAs, + # there exist manual accounts to link, and the item has at least one SFA + begin + unlinked_count = @enable_banking_unlinked_count_map[item.id] || 0 + manuals_exist = @enable_banking_has_unlinked_map[item.id] + sfa_any = if item.enable_banking_accounts.loaded? + item.enable_banking_accounts.any? + else + item.enable_banking_accounts.exists? + end + @enable_banking_show_relink_map[item.id] = (unlinked_count.to_i == 0 && manuals_exist && sfa_any) + rescue StandardError => e + Rails.logger.warn("Enable Banking card: CTA computation failed for item #{item.id}: #{e.class} - #{e.message}") + @enable_banking_show_relink_map[item.id] = false + end + end + + # Ensure maps are hashes even when items empty + @enable_banking_sync_stats_map ||= {} + @enable_banking_has_unlinked_map ||= {} + @enable_banking_unlinked_count_map ||= {} + @enable_banking_duplicate_only_map ||= {} + @enable_banking_show_relink_map ||= {} + end + + private + def compute_duplicate_only_flag(stats) + errs = Array(stats && stats["errors"]).map do |e| + if e.is_a?(Hash) + e["message"] || e[:message] + else + e.to_s + end + end + errs.present? && errs.all? { |m| m.to_s.downcase.include?("duplicate upstream account detected") } + rescue + false + end + end +end diff --git a/app/controllers/enable_banking_items_controller.rb b/app/controllers/enable_banking_items_controller.rb index 95fc35559..7ad9cd31e 100644 --- a/app/controllers/enable_banking_items_controller.rb +++ b/app/controllers/enable_banking_items_controller.rb @@ -1,7 +1,12 @@ class EnableBankingItemsController < ApplicationController + include EnableBankingItems::MapsHelper before_action :set_enable_banking_item, only: [ :update, :destroy, :sync, :select_bank, :authorize, :reauthorize, :setup_accounts, :complete_account_setup, :new_connection ] skip_before_action :verify_authenticity_token, only: [ :callback ] + def new + @enable_banking_item = Current.family.enable_banking_items.build + end + def create @enable_banking_item = Current.family.enable_banking_items.build(enable_banking_item_params) @enable_banking_item.name ||= "Enable Banking Connection" @@ -150,7 +155,7 @@ class EnableBankingItemsController < ApplicationController rescue Provider::EnableBanking::EnableBankingError => e if e.message.include?("REDIRECT_URI_NOT_ALLOWED") Rails.logger.error "Enable Banking redirect URI not allowed: #{e.message}" - redirect_to settings_providers_path, alert: t(".redirect_uri_not_allowed", default: "Redirect not allowew. Configure `%{callback_url}` in your Enable Banking application settings.", callback_url: enable_banking_callback_url) + redirect_to settings_providers_path, alert: t(".redirect_uri_not_allowed", default: "Redirect not allowed. Configure `%{callback_url}` in your Enable Banking application settings.", callback_url: enable_banking_callback_url) else Rails.logger.error "Enable Banking authorization error: #{e.message}" redirect_to settings_providers_path, alert: t(".authorization_failed", default: "Failed to start authorization: %{message}", message: e.message) @@ -403,6 +408,115 @@ class EnableBankingItemsController < ApplicationController redirect_to accounts_path, status: :see_other end + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + + # Filter out Enable Banking accounts that are already linked to any account + # (either via account_provider or legacy account association) + @available_enable_banking_accounts = Current.family.enable_banking_items + .includes(:enable_banking_accounts) + .flat_map(&:enable_banking_accounts) + .reject { |sfa| sfa.account_provider.present? || sfa.account.present? } + .sort_by { |sfa| sfa.updated_at || sfa.created_at } + .reverse + + # Always render a modal: either choices or a helpful empty-state + render :select_existing_account, layout: false + end + + def link_existing_account + @account = Current.family.accounts.find(params[:account_id]) + enable_banking_account = EnableBankingAccount.find(params[:enable_banking_account_id]) + + # Guard: only manual accounts can be linked (no existing provider links or legacy IDs) + if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present? + flash[:alert] = "Only manual accounts can be linked" + if turbo_frame_request? + return render turbo_stream: Array(flash_notification_stream_items) + else + return redirect_to account_path(@account), alert: flash[:alert] + end + end + + # Verify the Enable Banking account belongs to this family's Enable Banking items + unless enable_banking_account.enable_banking_item.present? && + Current.family.enable_banking_items.include?(enable_banking_account.enable_banking_item) + flash[:alert] = "Invalid Enable Banking account selected" + if turbo_frame_request? + render turbo_stream: Array(flash_notification_stream_items) + else + redirect_to account_path(@account), alert: flash[:alert] + end + return + end + + # Relink behavior: detach any legacy link and point provider link at the chosen account + Account.transaction do + enable_banking_account.lock! + + # Upsert the AccountProvider mapping deterministically + ap = AccountProvider.find_or_initialize_by(provider: enable_banking_account) + previous_account = ap.account + ap.account_id = @account.id + ap.save! + + # If the provider was previously linked to a different account in this family, + # and that account is now orphaned, quietly disable it so it disappears from the + # visible manual list. This mirrors the unified flow expectation that the provider + # follows the chosen account. + if previous_account && previous_account.id != @account.id && previous_account.family_id == @account.family_id + begin + previous_account.disable! + rescue => e + Rails.logger.warn("Failed to disable orphaned account #{previous_account.id}: #{e.class} - #{e.message}") + end + end + end + + if turbo_frame_request? + # Reload the item to ensure associations are fresh + enable_banking_account.reload + item = enable_banking_account.enable_banking_item + item.reload + + # Recompute data needed by Accounts#index partials + @manual_accounts = Account.uncached { + Current.family.accounts + .visible_manual + .order(:name) + .to_a + } + @enable_banking_items = Current.family.enable_banking_items.ordered.includes(:syncs) + build_enable_banking_maps_for(@enable_banking_items) + + flash[:notice] = "Account successfully linked to Enable Banking" + @account.reload + manual_accounts_stream = if @manual_accounts.any? + turbo_stream.update( + "manual-accounts", + partial: "accounts/index/manual_accounts", + locals: { accounts: @manual_accounts } + ) + else + turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts")) + end + + render turbo_stream: [ + # Optimistic removal of the specific account row if it exists in the DOM + turbo_stream.remove(ActionView::RecordIdentifier.dom_id(@account)), + manual_accounts_stream, + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(item), + partial: "enable_banking_items/enable_banking_item", + locals: { enable_banking_item: item } + ), + turbo_stream.replace("modal", view_context.turbo_frame_tag("modal")) + ] + Array(flash_notification_stream_items) + else + redirect_to accounts_path(cache_bust: SecureRandom.hex(6)), notice: "Account successfully linked to Enable Banking", status: :see_other + end + end + private def set_enable_banking_item diff --git a/app/models/provider/enable_banking_adapter.rb b/app/models/provider/enable_banking_adapter.rb index 379978bd8..4a3f0ef0e 100644 --- a/app/models/provider/enable_banking_adapter.rb +++ b/app/models/provider/enable_banking_adapter.rb @@ -5,6 +5,33 @@ class Provider::EnableBankingAdapter < Provider::Base # Register this adapter with the factory Provider::Factory.register("EnableBankingAccount", self) + # Define which account types this provider supports + def self.supported_account_types + %w[Depository CreditCard] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_enable_banking? + + [ { + key: "enable_banking", + name: "Enable Banking", + description: "Connect to your bank via Enable Banking", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.new_enable_banking_item_path( + accountable_type: accountable_type + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_enable_banking_items_path( + account_id: account_id + ) + } + } ] + end + def provider_name "enable_banking" end diff --git a/app/views/enable_banking_items/_enable_banking_item.html.erb b/app/views/enable_banking_items/_enable_banking_item.html.erb index 05c3d7328..4c13e8860 100644 --- a/app/views/enable_banking_items/_enable_banking_item.html.erb +++ b/app/views/enable_banking_items/_enable_banking_item.html.erb @@ -32,7 +32,7 @@ <% elsif enable_banking_item.requires_update? %>
<%= icon "alert-triangle", size: "sm", color: "warning" %> - <%= tag.span "Re-authorization required" %> + <%= tag.span "Reconnect" %>
<% else %>

@@ -56,7 +56,7 @@ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-white bg-warning hover:opacity-90 transition-colors", data: { turbo: false } do %> <%= icon "refresh-cw", size: "sm" %> - Re-authorize + Update <% end %> <% elsif Rails.env.development? %> <%= icon( diff --git a/app/views/enable_banking_items/new.html.erb b/app/views/enable_banking_items/new.html.erb new file mode 100644 index 000000000..26bc308f6 --- /dev/null +++ b/app/views/enable_banking_items/new.html.erb @@ -0,0 +1,117 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".link_enable_banking_title")) %> + <% dialog.with_body do %> + <% items = local_assigns[:enable_banking_items] || @enable_banking_items || Current.family.enable_banking_items.where.not(client_certificate: nil) %> + <% if items&.any? %> + <% + # Find the first item with valid session to use for "Add Connection" button + item_for_new_connection = items.find(&:session_valid?) + # Check if any item needs initial connection (configured but no session yet) + item_needing_connection = items.find { |i| !i.session_valid? && !i.session_expired? } + %> +

+ <% items.each do |item| %> +
+
+ <% if item.session_valid? %> +
+
+

<%= item.aspsp_name || "Connected Bank" %>

+

+ Session expires: <%= item.session_expires_at&.strftime("%b %d, %Y") || "Unknown" %> +

+
+ <% elsif item.session_expired? %> +
+
+

<%= item.aspsp_name || "Connection" %>

+

Session expired - re-authorization required

+
+ <% else %> +
+
+

Configured

+

Ready to connect a bank

+
+ <% end %> +
+ +
+ <% if item.session_valid? %> + <%= button_to sync_enable_banking_item_path(item), + method: :post, + class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-primary bg-container border border-primary hover:bg-gray-50 transition-colors", + data: { turbo: false } do %> + Sync + <% end %> + <% elsif item.session_expired? %> + <%= button_to reauthorize_enable_banking_item_path(item), + method: :post, + class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-warning hover:opacity-90 transition-colors", + data: { turbo: false } do %> + Reconnect + <% end %> + <% else %> + <%= link_to select_bank_enable_banking_item_path(item), + class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-gray-900 hover:bg-gray-800 transition-colors", + data: { turbo_frame: "modal" } do %> + Connect Bank + <% end %> + <% end %> + + <%= button_to enable_banking_item_path(item), + method: :delete, + class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors", + data: { turbo_confirm: "Are you sure you want to remove this connection?" } do %> + Remove + <% end %> +
+
+ <% end %> + + <%# Add Connection button below the list - only show if we have a valid session to copy credentials from %> + <% if item_for_new_connection %> +
+ <%= button_to new_connection_enable_banking_item_path(item_for_new_connection), + method: :post, + class: "inline-flex items-center gap-2 justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 transition-colors", + data: { turbo_frame: "modal" } do %> + <%= icon "plus", size: "sm" %> + Add Connection + <% end %> +
+ <% end %> +
+ <% else %> +
+
+ <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> +
+

Enable Banking connection not configured

+

Before you can link Enable Banking accounts, you need to configure your Enable Banking connection.

+
+
+ +
+

Setup Steps:

+
    +
  1. Go to Settings → Bank Sync Providers
  2. +
  3. Find the Enable Banking section
  4. +
  5. Enter your Enable Banking credentials
  6. +
  7. Return here to link your accounts
  8. +
+
+ +
+ <%= link_to settings_providers_path, + class: "w-full inline-flex items-center justify-center rounded-lg font-medium whitespace-nowrap rounded-lg hidden md:inline-flex px-3 py-2 text-sm text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400", + data: { turbo: false } do %> + Go to Provider Settings + <% end %> +
+
+ <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/enable_banking_items/select_existing_account.html.erb b/app/views/enable_banking_items/select_existing_account.html.erb new file mode 100644 index 000000000..7ff7aedad --- /dev/null +++ b/app/views/enable_banking_items/select_existing_account.html.erb @@ -0,0 +1,40 @@ +<%# Modal: Link an existing manual account to a Enable Banking account %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Link Enable Banking account") %> + + <% dialog.with_body do %> + <% if @available_enable_banking_accounts.blank? %> +
+

All Enable Banking accounts appear to be linked already.

+ +
+ <% else %> + <%= form_with url: link_existing_account_enable_banking_items_path, method: :post, class: "space-y-4" do %> + <%= hidden_field_tag :account_id, @account.id %> +
+ <% @available_enable_banking_accounts.each do |eba| %> + + <% end %> +
+ +
+ <%= render DS::Button.new(text: "Link", variant: :primary, icon: "link-2", type: :submit) %> + <%= render DS::Link.new(text: "Cancel", variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %> +
+ <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/settings/providers/_enable_banking_panel.html.erb b/app/views/settings/providers/_enable_banking_panel.html.erb index 3fe8d1203..e34cc4544 100644 --- a/app/views/settings/providers/_enable_banking_panel.html.erb +++ b/app/views/settings/providers/_enable_banking_panel.html.erb @@ -127,13 +127,13 @@

<%= item.aspsp_name || "Connection" %>

-

Session expired - re-authorization required

+

Session expired - reconnect

<% else %>

Configured

-

Ready to connect a bank

+

Ready to link accounts

<% end %> @@ -151,7 +151,7 @@ method: :post, class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-warning hover:opacity-90 transition-colors", data: { turbo: false } do %> - Re-authorize + Reconnect <% end %> <% else %> <%= link_to select_bank_enable_banking_item_path(item), diff --git a/app/views/simplefin_items/edit.html.erb b/app/views/simplefin_items/edit.html.erb index 84c4a6238..f0d3e0811 100644 --- a/app/views/simplefin_items/edit.html.erb +++ b/app/views/simplefin_items/edit.html.erb @@ -16,12 +16,12 @@ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>

- Your SimpleFin connection needs to be updated: + Your SimpleFIN connection needs to be updated:

    -
  1. Visit SimpleFin Bridge to create a new setup token
  2. +
  3. Visit SimpleFIN Bridge to create a new setup token
  4. Copy the token and paste it below
  5. -
  6. Click "Update Connection" to restore access
  7. +
  8. Click "Update" to restore access
@@ -46,7 +46,7 @@
<%= render DS::Button.new( - text: "Update Connection", + text: "Update", variant: "primary", icon: "refresh-cw", type: "submit", diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml index f83c6541b..fbb0a51d6 100644 --- a/config/locales/views/plaid_items/en.yml +++ b/config/locales/views/plaid_items/en.yml @@ -19,11 +19,11 @@ en: no_accounts_description: We could not load any accounts from this financial institution. no_accounts_title: No accounts found - requires_update: Requires re-authentication + requires_update: Reconnect status: Last synced %{timestamp} ago status_never: Requires data sync syncing: Syncing... - update: Update connection + update: Update select_existing_account: title: "Link %{account_name} to Plaid" description: Select a Plaid account to link to your existing account diff --git a/config/locales/views/simplefin_items/en.yml b/config/locales/views/simplefin_items/en.yml index e07501b12..af71acae6 100644 --- a/config/locales/views/simplefin_items/en.yml +++ b/config/locales/views/simplefin_items/en.yml @@ -44,7 +44,7 @@ en: error: Error occurred while syncing data no_accounts_description: This connection doesn't have any synchronized accounts yet. no_accounts_title: No accounts found - requires_update: Requires re-authentication + requires_update: Reconnect setup_needed: New accounts ready to set up setup_description: Choose account types for your newly imported SimpleFin accounts. setup_action: Set Up New Accounts @@ -52,7 +52,7 @@ en: status_never: Never synced status_with_summary: "Last synced %{timestamp} ago • %{summary}" syncing: Syncing... - update: Update connection + update: Update select_existing_account: title: "Link %{account_name} to SimpleFIN" description: Select a SimpleFIN account to link to your existing account diff --git a/config/routes.rb b/config/routes.rb index c854eded2..a8321482d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,10 +2,12 @@ require "sidekiq/web" require "sidekiq/cron/web" Rails.application.routes.draw do - resources :enable_banking_items, only: [ :create, :update, :destroy ] do + resources :enable_banking_items, only: [ :new, :create, :update, :destroy ] do collection do get :callback post :link_accounts + get :select_existing_account + post :link_existing_account end member do post :sync