diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c363fdab32e..6628972ac21 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -1,5 +1,5 @@ class AccountsController < ApplicationController - before_action :set_account, only: %i[sync sparkline toggle_active show destroy] + before_action :set_account, only: %i[sync sparkline toggle_active show destroy unlink confirm_unlink select_provider] include Periodable def index @@ -17,7 +17,7 @@ def new def sync_all family.sync_later - redirect_to accounts_path, notice: "Syncing accounts..." + redirect_to accounts_path, notice: t("accounts.sync_all.syncing") end def show @@ -71,10 +71,93 @@ def toggle_active def destroy if @account.linked? - redirect_to account_path(@account), alert: "Cannot delete a linked account" + redirect_to account_path(@account), alert: t("accounts.destroy.cannot_delete_linked") else @account.destroy_later - redirect_to accounts_path, notice: "Account scheduled for deletion" + redirect_to accounts_path, notice: t("accounts.destroy.success", type: @account.accountable_type) + end + end + + def confirm_unlink + unless @account.linked? + redirect_to account_path(@account), alert: t("accounts.unlink.not_linked") + end + end + + def unlink + unless @account.linked? + redirect_to account_path(@account), alert: t("accounts.unlink.not_linked") + return + end + + begin + Account.transaction do + # Remove new system links (account_providers join table) + @account.account_providers.destroy_all + + # Remove legacy system links (foreign keys) + @account.update!(plaid_account_id: nil, simplefin_account_id: nil) + end + + redirect_to accounts_path, notice: t("accounts.unlink.success") + rescue ActiveRecord::RecordInvalid => e + redirect_to account_path(@account), alert: t("accounts.unlink.error", error: e.message) + rescue StandardError => e + Rails.logger.error "Failed to unlink account #{@account.id}: #{e.message}" + redirect_to account_path(@account), alert: t("accounts.unlink.error", error: t("accounts.unlink.generic_error")) + end + end + + def select_provider + if @account.linked? + redirect_to account_path(@account), alert: t("accounts.select_provider.already_linked") + return + end + + @available_providers = [] + + # Check SimpleFIN + if family.can_connect_simplefin? + @available_providers << { + name: "SimpleFIN", + key: "simplefin", + description: "Connect to your bank via SimpleFIN", + path: select_existing_account_simplefin_items_path(account_id: @account.id) + } + end + + # Check Plaid US + if family.can_connect_plaid_us? + @available_providers << { + name: "Plaid", + key: "plaid_us", + description: "Connect to your US bank via Plaid", + path: select_existing_account_plaid_items_path(account_id: @account.id, region: "us") + } + end + + # Check Plaid EU + if family.can_connect_plaid_eu? + @available_providers << { + name: "Plaid (EU)", + key: "plaid_eu", + description: "Connect to your EU bank via Plaid", + path: select_existing_account_plaid_items_path(account_id: @account.id, region: "eu") + } + end + + # Check Lunch Flow + if family.can_connect_lunchflow? + @available_providers << { + name: "Lunch Flow", + key: "lunchflow", + description: "Connect to your bank via Lunch Flow", + path: select_existing_account_lunchflow_items_path(account_id: @account.id) + } + end + + if @available_providers.empty? + redirect_to account_path(@account), alert: t("accounts.select_provider.no_providers") end end diff --git a/app/controllers/lunchflow_items_controller.rb b/app/controllers/lunchflow_items_controller.rb index 61b266d4060..9ba1d26466b 100644 --- a/app/controllers/lunchflow_items_controller.rb +++ b/app/controllers/lunchflow_items_controller.rb @@ -98,7 +98,7 @@ def link_accounts # Create or find lunchflow_item for this family lunchflow_item = Current.family.lunchflow_items.first_or_create!( - name: "Lunchflow Connection" + name: "Lunch Flow Connection" ) # Fetch account details from API @@ -279,7 +279,7 @@ def link_existing_account # Create or find lunchflow_item for this family lunchflow_item = Current.family.lunchflow_items.first_or_create!( - name: "Lunchflow Connection" + name: "Lunch Flow Connection" ) # Fetch account details from API @@ -338,7 +338,7 @@ def new def create @lunchflow_item = Current.family.lunchflow_items.build(lunchflow_params) - @lunchflow_item.name = "Lunchflow Connection" + @lunchflow_item.name = "Lunch Flow Connection" if @lunchflow_item.save # Trigger initial sync to fetch accounts diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 8f32084f8de..f2846d04e52 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -48,6 +48,48 @@ def sync 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: "No available Plaid accounts to link. Please connect a new Plaid account first." + 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: "Invalid Plaid account selected" + 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: "This Plaid account is already linked" + return + end + + # Create the link via AccountProvider + AccountProvider.create!( + account: @account, + provider: plaid_account + ) + + redirect_to accounts_path, notice: "Account successfully linked to Plaid" + end + private def set_plaid_item @plaid_item = Current.family.plaid_items.find(params[:id]) diff --git a/app/controllers/simplefin_items_controller.rb b/app/controllers/simplefin_items_controller.rb index 3b2696ed3e0..2668a60eb2f 100644 --- a/app/controllers/simplefin_items_controller.rb +++ b/app/controllers/simplefin_items_controller.rb @@ -186,6 +186,46 @@ def complete_account_setup redirect_to accounts_path, notice: t(".success") end + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + + # Get all SimpleFIN accounts from this family's SimpleFIN items + # that are not yet linked to any account + @available_simplefin_accounts = Current.family.simplefin_items + .includes(:simplefin_accounts) + .flat_map(&:simplefin_accounts) + .select { |sa| sa.account_provider.nil? && sa.account.nil? } # Not linked via new or legacy system + + if @available_simplefin_accounts.empty? + redirect_to account_path(@account), alert: "No available SimpleFIN accounts to link. Please connect a new SimpleFIN account first." + end + end + + def link_existing_account + @account = Current.family.accounts.find(params[:account_id]) + simplefin_account = SimplefinAccount.find(params[:simplefin_account_id]) + + # Verify the SimpleFIN account belongs to this family's SimpleFIN items + unless Current.family.simplefin_items.include?(simplefin_account.simplefin_item) + redirect_to account_path(@account), alert: "Invalid SimpleFIN account selected" + return + end + + # Verify the SimpleFIN account is not already linked + if simplefin_account.account_provider.present? || simplefin_account.account.present? + redirect_to account_path(@account), alert: "This SimpleFIN account is already linked" + return + end + + # Create the link via AccountProvider + AccountProvider.create!( + account: @account, + provider: simplefin_account + ) + + redirect_to accounts_path, notice: "Account successfully linked to SimpleFIN" + end + private def set_simplefin_item diff --git a/app/models/account.rb b/app/models/account.rb index b55aafd37f2..428cb513d7a 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -22,7 +22,11 @@ class Account < ApplicationRecord scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } - scope :manual, -> { left_joins(:account_providers).where(account_providers: { id: nil }) } + scope :manual, -> { + left_joins(:account_providers) + .where(account_providers: { id: nil }) + .where(plaid_account_id: nil, simplefin_account_id: nil) + } has_one_attached :logo diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index 328cf5f1076..d3830ddda2e 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -12,7 +12,7 @@ module Account::Linkable # A "linked" account gets transaction and balance data from a third party like Plaid or SimpleFin def linked? - account_providers.any? + account_providers.any? || plaid_account.present? || simplefin_account.present? end # An "offline" or "unlinked" account is one where the user tracks values and @@ -43,7 +43,14 @@ def provider_for(provider_type) # Convenience method to get the provider name def provider_name - provider&.provider_name + # Try new system first + return provider&.provider_name if provider.present? + + # Fall back to legacy system + return "plaid" if plaid_account.present? + return "simplefin" if simplefin_account.present? + + nil end # Check if account is linked to a specific provider diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 70c57438f17..85c4b5ca089 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -16,12 +16,22 @@ class PlaidItem < ApplicationRecord has_one_attached :logo has_many :plaid_accounts, dependent: :destroy - has_many :accounts, through: :plaid_accounts + has_many :legacy_accounts, through: :plaid_accounts, source: :account scope :active, -> { where(scheduled_for_deletion: false) } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } + # Get accounts from both new and legacy systems + def accounts + # Preload associations to avoid N+1 queries + plaid_accounts + .includes(:account, account_provider: :account) + .map(&:current_account) + .compact + .uniq + end + def get_update_link_token(webhooks_url:, redirect_url:) family.get_link_token( webhooks_url: webhooks_url, diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index 4d4b9f08ad1..8eb44d7724e 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -18,12 +18,22 @@ class SimplefinItem < ApplicationRecord has_one_attached :logo has_many :simplefin_accounts, dependent: :destroy - has_many :accounts, through: :simplefin_accounts + has_many :legacy_accounts, through: :simplefin_accounts, source: :account scope :active, -> { where(scheduled_for_deletion: false) } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } + # Get accounts from both new and legacy systems + def accounts + # Preload associations to avoid N+1 queries + simplefin_accounts + .includes(:account, account_provider: :account) + .map(&:current_account) + .compact + .uniq + end + def destroy_later update!(scheduled_for_deletion: true) DestroyJob.perform_later(self) diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index b332a294bf2..584b16da207 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -33,13 +33,20 @@ <%= icon("pencil-line", size: "sm") %> <% end %> - <% if !account.account_providers.exists? && (account.accountable_type == "Depository" || account.accountable_type == "CreditCard") %> - <%= link_to select_existing_account_lunchflow_items_path(account_id: account.id, return_to: return_to), + <% if !account.linked? && (account.accountable_type == "Depository" || account.accountable_type == "CreditCard") %> + <%= link_to select_provider_account_path(account), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1", - title: t("accounts.account.link_lunchflow") do %> + title: t("accounts.account.link_provider") do %> <%= icon("link", size: "sm") %> <% end %> + <% elsif account.linked? %> + <%= link_to confirm_unlink_account_path(account), + data: { turbo_frame: :modal }, + class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1", + title: t("accounts.account.unlink_provider") do %> + <%= icon("unlink", size: "sm") %> + <% end %> <% end %> <% end %> diff --git a/app/views/accounts/confirm_unlink.html.erb b/app/views/accounts/confirm_unlink.html.erb new file mode 100644 index 00000000000..5b77c48081a --- /dev/null +++ b/app/views/accounts/confirm_unlink.html.erb @@ -0,0 +1,31 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("accounts.confirm_unlink.title")) %> + + <% dialog.with_body do %> +
+ <%= t("accounts.confirm_unlink.description_html", account_name: @account.name, provider_name: @account.provider_name) %> +
+ +<%= t("accounts.confirm_unlink.warning_title") %>
++ <%= t("accounts.select_provider.description", account_name: @account.name) %> +
+ +<%= provider[:name] %>
+<%= provider[:description] %>
++ <%= t(".description") %> +
+ + ++ <%= t(".description") %> +
+ + +