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? %>
@@ -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? } + %> +
<%= item.aspsp_name || "Connected Bank" %>
++ Session expires: <%= item.session_expires_at&.strftime("%b %d, %Y") || "Unknown" %> +
+<%= item.aspsp_name || "Connection" %>
+Session expired - re-authorization required
+Configured
+Ready to connect a bank
+Enable Banking connection not configured
+Before you can link Enable Banking accounts, you need to configure your Enable Banking connection.
+Setup Steps:
+All Enable Banking accounts appear to be linked already.
+<%= item.aspsp_name || "Connection" %>
-Session expired - re-authorization required
+Session expired - reconnect
Configured
-Ready to connect a bank
+Ready to link accounts
- Your SimpleFin connection needs to be updated: + Your SimpleFIN connection needs to be updated: