diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 1bc693403..1e33156d2 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -141,11 +141,27 @@ class AccountsController < ApplicationController begin Account.transaction do + # Detach holdings from provider links before destroying them + provider_link_ids = @account.account_providers.pluck(:id) + if provider_link_ids.any? + Holding.where(account_provider_id: provider_link_ids).update_all(account_provider_id: nil) + end + + # Capture SimplefinAccount before clearing FK (so we can destroy it) + simplefin_account_to_destroy = @account.simplefin_account + # 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) + + # Destroy the SimplefinAccount record so it doesn't cause stale account issues + # This is safe because: + # - Account data (transactions, holdings, balances) lives on the Account, not SimplefinAccount + # - SimplefinAccount only caches API data which is regenerated on reconnect + # - If user reconnects SimpleFin later, a new SimplefinAccount will be created + simplefin_account_to_destroy&.destroy! end redirect_to accounts_path, notice: t("accounts.unlink.success") diff --git a/app/controllers/simplefin_items_controller.rb b/app/controllers/simplefin_items_controller.rb index b3e02a6be..2aeb01f5d 100644 --- a/app/controllers/simplefin_items_controller.rb +++ b/app/controllers/simplefin_items_controller.rb @@ -201,17 +201,33 @@ class SimplefinItemsController < ApplicationController message: "No additional options needed for Other Assets." } } + + # Detect stale accounts: linked in DB but no longer in upstream SimpleFin API + @stale_simplefin_accounts = detect_stale_simplefin_accounts + if @stale_simplefin_accounts.any? + # Build list of target accounts for "move transactions to" dropdown + # Only show accounts from this SimpleFin connection (excluding stale ones) + stale_account_ids = @stale_simplefin_accounts.map { |sfa| sfa.current_account&.id }.compact + @target_accounts = @simplefin_item.accounts + .reject { |acct| stale_account_ids.include?(acct.id) } + .sort_by(&:name) + end end def complete_account_setup account_types = params[:account_types] || {} account_subtypes = params[:account_subtypes] || {} + stale_account_actions = permitted_stale_account_actions # Update sync start date from form if params[:sync_start_date].present? @simplefin_item.update!(sync_start_date: params[:sync_start_date]) end + # Process stale account actions first + stale_results = process_stale_account_actions(stale_account_actions) + stale_action_errors = stale_results[:errors] || [] + # Valid account types for this provider (plus Crypto and OtherAsset which SimpleFIN UI allows) valid_types = Provider::SimplefinAdapter.supported_account_types + [ "Crypto", "OtherAsset" ] @@ -275,6 +291,17 @@ class SimplefinItemsController < ApplicationController else flash[:notice] = t(".no_accounts") end + + # Add stale account results to flash + if stale_results[:deleted] > 0 || stale_results[:moved] > 0 + stale_message = t(".stale_accounts_processed", deleted: stale_results[:deleted], moved: stale_results[:moved]) + flash[:notice] = [ flash[:notice], stale_message ].compact.join(" ") + end + + # Warn about any stale account action failures + if stale_action_errors.any? + flash[:alert] = t(".stale_accounts_errors", count: stale_action_errors.size) + end if turbo_frame_request? # Recompute data needed by Accounts#index partials @manual_accounts = Account.uncached { @@ -451,6 +478,24 @@ class SimplefinItemsController < ApplicationController params.require(:simplefin_item).permit(:setup_token, :sync_start_date) end + def permitted_stale_account_actions + return {} unless params[:stale_account_actions].is_a?(ActionController::Parameters) + + # Permit the nested structure: stale_account_actions[simplefin_account_id][action|target_account_id] + params[:stale_account_actions].to_unsafe_h.each_with_object({}) do |(simplefin_account_id, action_params), result| + next unless simplefin_account_id.present? && action_params.is_a?(Hash) + + # Validate simplefin_account_id is a valid UUID format to prevent injection + next unless simplefin_account_id.to_s.match?(/\A[0-9a-f-]+\z/i) + + permitted = {} + permitted[:action] = action_params[:action] if %w[delete move skip].include?(action_params[:action]) + permitted[:target_account_id] = action_params[:target_account_id] if action_params[:target_account_id].present? + + result[simplefin_account_id] = permitted if permitted[:action].present? + end + end + def render_error(message, setup_token = nil, context: :new) if context == :edit # Keep the persisted record and assign the token for re-render @@ -472,4 +517,106 @@ class SimplefinItemsController < ApplicationController render context, status: :unprocessable_entity end end + + # Detect stale SimpleFin accounts: linked in DB but no longer in upstream API + def detect_stale_simplefin_accounts + # Get upstream account IDs from the last sync's raw_payload + raw_payload = @simplefin_item.raw_payload + return [] if raw_payload.blank? + + upstream_ids = raw_payload.with_indifferent_access[:accounts]&.map { |a| a[:id].to_s } || [] + return [] if upstream_ids.empty? + + # Find SimplefinAccounts that are linked but not in upstream + @simplefin_item.simplefin_accounts + .includes(:account, account_provider: :account) + .select { |sfa| sfa.current_account.present? && !upstream_ids.include?(sfa.account_id) } + end + + # Process user-selected actions for stale accounts + def process_stale_account_actions(stale_actions) + results = { deleted: 0, moved: 0, skipped: 0, errors: [] } + return results if stale_actions.blank? + + stale_actions.each do |simplefin_account_id, action_params| + action = action_params[:action] + next if action.blank? || action == "skip" + + sfa = @simplefin_item.simplefin_accounts.find_by(id: simplefin_account_id) + next unless sfa + + account = sfa.current_account + next unless account + + case action + when "delete" + if handle_stale_account_delete(sfa, account) + results[:deleted] += 1 + else + results[:errors] << { account: account.name, action: "delete" } + end + when "move" + target_account_id = action_params[:target_account_id] + if target_account_id.present? && handle_stale_account_move(sfa, account, target_account_id) + results[:moved] += 1 + else + results[:errors] << { account: account.name, action: "move" } + end + else + results[:skipped] += 1 + end + end + + results + end + + def handle_stale_account_delete(simplefin_account, account) + ActiveRecord::Base.transaction do + # Destroy the Account (cascades to entries/holdings) + account.destroy! + # Destroy the SimplefinAccount + simplefin_account.destroy! + end + true + rescue => e + Rails.logger.error("Failed to delete stale account: #{e.class} - #{e.message}") + false + end + + def handle_stale_account_move(simplefin_account, source_account, target_account_id) + target_account = @simplefin_item.accounts.find { |acct| acct.id.to_s == target_account_id.to_s } + return false unless target_account + + ActiveRecord::Base.transaction do + # Handle transfers that would become invalid after moving entries. + # Transfers linking source entries to target entries would end up with both + # entries in the same account, violating transfer_has_different_accounts validation. + source_entry_ids = source_account.entries.pluck(:id) + target_entry_ids = target_account.entries.pluck(:id) + + if source_entry_ids.any? && target_entry_ids.any? + # Find and destroy transfers between source and target accounts + # Use find_each + destroy! to invoke Transfer's custom destroy! callbacks + # which reset transaction kinds to "standard" + Transfer.where(inflow_transaction_id: source_entry_ids, outflow_transaction_id: target_entry_ids) + .or(Transfer.where(inflow_transaction_id: target_entry_ids, outflow_transaction_id: source_entry_ids)) + .find_each(&:destroy!) + end + + # Move all entries to target account + source_account.entries.update_all(account_id: target_account.id) + + # Destroy the now-empty source account + source_account.destroy! + # Destroy the SimplefinAccount + simplefin_account.destroy! + end + + # Trigger sync on target account to recalculate balances (after commit) + target_account.sync_later + true + rescue => e + Rails.logger.error("Failed to move transactions from stale account: #{e.class} - #{e.message}") + false + end end diff --git a/app/javascript/controllers/stale_account_action_controller.js b/app/javascript/controllers/stale_account_action_controller.js new file mode 100644 index 000000000..787ce55d9 --- /dev/null +++ b/app/javascript/controllers/stale_account_action_controller.js @@ -0,0 +1,25 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["moveRadio", "targetSelect"] + static values = { accountId: String } + + connect() { + this.updateTargetVisibility() + } + + updateTargetVisibility() { + if (!this.hasTargetSelectTarget || !this.hasMoveRadioTarget) return + + const moveRadio = this.moveRadioTarget + const targetSelect = this.targetSelectTarget + + if (moveRadio?.checked) { + targetSelect.disabled = false + targetSelect.classList.remove("opacity-50", "cursor-not-allowed") + } else { + targetSelect.disabled = true + targetSelect.classList.add("opacity-50", "cursor-not-allowed") + } + } +} diff --git a/app/views/simplefin_items/_stale_account_row.html.erb b/app/views/simplefin_items/_stale_account_row.html.erb new file mode 100644 index 000000000..afb0e42c6 --- /dev/null +++ b/app/views/simplefin_items/_stale_account_row.html.erb @@ -0,0 +1,65 @@ +<% account = simplefin_account.current_account %> +<% transaction_count = account&.entries&.where(entryable_type: "Transaction")&.count || 0 %> + +
+
+
+

+ <%= simplefin_account.name %> + <% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %> + • <%= simplefin_account.org_data["name"] %> + <% end %> +

+

+ <%= number_to_currency(simplefin_account.current_balance || 0, unit: simplefin_account.currency || "USD") %> + + <%= t("simplefin_items.setup_accounts.stale_accounts.transaction_count", count: transaction_count) %> +

+
+
+ +
+ + <%= t("simplefin_items.setup_accounts.stale_accounts.action_prompt") %> + + + + + <% if target_accounts&.any? %> +
+ + <%= select_tag "stale_account_actions[#{simplefin_account.id}][target_account_id]", + options_from_collection_for_select(target_accounts, :id, :name), + class: "appearance-none bg-container border border-primary rounded-md px-2 py-1 text-sm text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none ml-6 max-w-[200px] truncate disabled:opacity-50 disabled:cursor-not-allowed", + disabled: true, + data: { stale_account_action_target: "targetSelect" } %> +
+ <% end %> + + +
+
diff --git a/app/views/simplefin_items/setup_accounts.html.erb b/app/views/simplefin_items/setup_accounts.html.erb index 685b8e66b..1d30108e0 100644 --- a/app/views/simplefin_items/setup_accounts.html.erb +++ b/app/views/simplefin_items/setup_accounts.html.erb @@ -99,6 +99,29 @@ <% end %> + + <% if @stale_simplefin_accounts&.any? %> + +
+
+
+ <%= icon "alert-triangle", size: "sm", class: "text-warning mt-0.5 flex-shrink-0" %> +
+

+ <%= t("simplefin_items.setup_accounts.stale_accounts.title") %> +

+

+ <%= t("simplefin_items.setup_accounts.stale_accounts.description") %> +

+
+
+
+ + <% @stale_simplefin_accounts.each do |simplefin_account| %> + <%= render "stale_account_row", simplefin_account: simplefin_account, target_accounts: @target_accounts %> + <% end %> +
+ <% end %>
diff --git a/config/locales/views/simplefin_items/en.yml b/config/locales/views/simplefin_items/en.yml index 5ff871805..a84c335e3 100644 --- a/config/locales/views/simplefin_items/en.yml +++ b/config/locales/views/simplefin_items/en.yml @@ -2,43 +2,60 @@ en: simplefin_items: new: - title: Connect SimpleFin + title: Connect SimpleFIN setup_token: Setup token - setup_token_placeholder: paste your SimpleFin setup token + setup_token_placeholder: paste your SimpleFIN setup token connect: Connect cancel: Cancel create: - success: SimpleFin connection added successfully! Your accounts will appear shortly as they sync in the background. + success: SimpleFIN connection added successfully! Your accounts will appear shortly as they sync in the background. errors: - blank_token: Please enter a SimpleFin setup token. - invalid_token: Invalid setup token. Please check that you copied the complete token from SimpleFin Bridge. + blank_token: Please enter a SimpleFIN setup token. + invalid_token: Invalid setup token. Please check that you copied the complete token from SimpleFIN Bridge. token_compromised: The setup token may be compromised, expired, or already used. Please create a new one. create_failed: "Failed to connect: %{message}" unexpected: An unexpected error occurred. Please try again or contact support. destroy: - success: SimpleFin connection will be removed + success: SimpleFIN connection will be removed update: - success: SimpleFin connection updated successfully! Your accounts are being reconnected. + success: SimpleFIN connection updated successfully! Your accounts are being reconnected. errors: - blank_token: Please enter a SimpleFin setup token. - invalid_token: Invalid setup token. Please check that you copied the complete token from SimpleFin Bridge. + blank_token: Please enter a SimpleFIN setup token. + invalid_token: Invalid setup token. Please check that you copied the complete token from SimpleFIN Bridge. token_compromised: The setup token may be compromised, expired, or already used. Please create a new one. update_failed: "Failed to update connection: %{message}" unexpected: An unexpected error occurred. Please try again or contact support. edit: setup_token: - label: "SimpleFin Setup Token:" - placeholder: "Paste your SimpleFin setup token here..." + label: "SimpleFIN Setup Token:" + placeholder: "Paste your SimpleFIN setup token here..." help_text: "The token should be a long string starting with letters and numbers" + setup_accounts: + stale_accounts: + title: "Accounts No Longer in SimpleFIN" + description: "These accounts exist in your database but are no longer provided by SimpleFIN. This can happen when account configurations change upstream." + action_prompt: "What would you like to do?" + action_delete: "Delete account and all transactions" + action_move: "Move transactions to:" + action_skip: "Skip for now" + transaction_count: + one: "%{count} transaction" + other: "%{count} transactions" complete_account_setup: all_skipped: "All accounts were skipped. No accounts were created." no_accounts: "No accounts to set up." - success: "Successfully created %{count} SimpleFIN account(s)! Your transactions and holdings are being imported in the background." + success: + one: "Successfully created %{count} SimpleFIN account! Your transactions and holdings are being imported in the background." + other: "Successfully created %{count} SimpleFIN accounts! Your transactions and holdings are being imported in the background." + stale_accounts_processed: "Stale accounts: %{deleted} deleted, %{moved} moved." + stale_accounts_errors: + one: "%{count} stale account action failed. Check logs for details." + other: "%{count} stale account actions failed. Check logs for details." simplefin_item: add_new: Add new connection confirm_accept: Delete connection confirm_body: This will permanently delete all the accounts in this group and all associated data. - confirm_title: Delete SimpleFin connection? + confirm_title: Delete SimpleFIN connection? delete: Delete deletion_in_progress: "(deletion in progress...)" error: Error occurred while syncing data @@ -46,7 +63,7 @@ en: no_accounts_title: No accounts found requires_update: Reconnect setup_needed: New accounts ready to set up - setup_description: Choose account types for your newly imported SimpleFin accounts. + setup_description: Choose account types for your newly imported SimpleFIN accounts. setup_action: Set Up New Accounts status: Last synced %{timestamp} ago status_never: Never synced @@ -68,4 +85,4 @@ en: success: Account successfully linked to SimpleFIN errors: only_manual: Only manual accounts can be linked - invalid_simplefin_account: Invalid SimpleFIN account selected \ No newline at end of file + invalid_simplefin_account: Invalid SimpleFIN account selected diff --git a/config/locales/views/simplefin_items/update.en.yml b/config/locales/views/simplefin_items/update.en.yml index 8a92c5444..cea719954 100644 --- a/config/locales/views/simplefin_items/update.en.yml +++ b/config/locales/views/simplefin_items/update.en.yml @@ -1,7 +1,7 @@ en: simplefin_items: update: - success: "SimpleFin connection updated." + success: "SimpleFIN connection updated." errors: - blank_token: "Missing SimpleFin access token. Please provide a token or use Link Existing Accounts to proceed." - update_failed: "Failed to update SimpleFin connection: %{message}" + blank_token: "Missing SimpleFIN access token. Please provide a token or use Link Existing Accounts to proceed." + update_failed: "Failed to update SimpleFIN connection: %{message}" diff --git a/test/controllers/simplefin_items_controller_test.rb b/test/controllers/simplefin_items_controller_test.rb index 1242dd771..f702a0006 100644 --- a/test/controllers/simplefin_items_controller_test.rb +++ b/test/controllers/simplefin_items_controller_test.rb @@ -168,7 +168,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest } assert_redirected_to accounts_path - assert_equal "SimpleFin connection updated.", flash[:notice] + assert_equal "SimpleFIN connection updated.", flash[:notice] end test "should handle update with invalid token" do @@ -179,7 +179,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest } assert_response :unprocessable_entity - assert_includes response.body, I18n.t("simplefin_items.update.errors.blank_token", default: "Please enter a SimpleFin setup token") + assert_includes response.body, I18n.t("simplefin_items.update.errors.blank_token", default: "Please enter a SimpleFIN setup token") end test "should transfer accounts when updating simplefin item token" do @@ -187,7 +187,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest token = Base64.strict_encode64("https://example.com/claim") - # Create old SimpleFin accounts linked to Maybe accounts + # Create old SimpleFIN accounts linked to Maybe accounts old_simplefin_account1 = @simplefin_item.simplefin_accounts.create!( name: "Test Checking", account_id: "sf_account_123", @@ -203,7 +203,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest account_type: "depository" ) - # Create Maybe accounts linked to the SimpleFin accounts + # Create Maybe accounts linked to the SimpleFIN accounts maybe_account1 = Account.create!( family: @family, name: "Checking Account", @@ -223,7 +223,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest simplefin_account_id: old_simplefin_account2.id ) - # Update old SimpleFin accounts to reference the Maybe accounts + # Update old SimpleFIN accounts to reference the Maybe accounts old_simplefin_account1.update!(account: maybe_account1) old_simplefin_account2.update!(account: maybe_account2) @@ -261,31 +261,31 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to accounts_path - assert_equal "SimpleFin connection updated.", flash[:notice] + assert_equal "SimpleFIN connection updated.", flash[:notice] - # Verify accounts were transferred to new SimpleFin accounts + # Verify accounts were transferred to new SimpleFIN accounts assert Account.exists?(maybe_account1.id), "maybe_account1 should still exist" assert Account.exists?(maybe_account2.id), "maybe_account2 should still exist" maybe_account1.reload maybe_account2.reload - # Find the new SimpleFin item that was created + # Find the new SimpleFIN item that was created new_simplefin_item = @family.simplefin_items.where.not(id: @simplefin_item.id).first - assert_not_nil new_simplefin_item, "New SimpleFin item should have been created" + assert_not_nil new_simplefin_item, "New SimpleFIN item should have been created" new_sf_account1 = new_simplefin_item.simplefin_accounts.find_by(account_id: "sf_account_123") new_sf_account2 = new_simplefin_item.simplefin_accounts.find_by(account_id: "sf_account_456") - assert_not_nil new_sf_account1, "New SimpleFin account with ID sf_account_123 should exist" - assert_not_nil new_sf_account2, "New SimpleFin account with ID sf_account_456 should exist" + assert_not_nil new_sf_account1, "New SimpleFIN account with ID sf_account_123 should exist" + assert_not_nil new_sf_account2, "New SimpleFIN account with ID sf_account_456 should exist" assert_equal new_sf_account1.id, maybe_account1.simplefin_account_id assert_equal new_sf_account2.id, maybe_account2.simplefin_account_id # The old item will be deleted asynchronously; until then, legacy links should be moved. - # Verify old SimpleFin item is scheduled for deletion + # Verify old SimpleFIN item is scheduled for deletion @simplefin_item.reload assert @simplefin_item.scheduled_for_deletion? end @@ -295,7 +295,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest token = Base64.strict_encode64("https://example.com/claim") - # Create old SimpleFin account + # Create old SimpleFIN account old_simplefin_account = @simplefin_item.simplefin_accounts.create!( name: "Test Checking", account_id: "sf_account_123", @@ -304,7 +304,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest account_type: "depository" ) - # Create Maybe account linked to the SimpleFin account + # Create Maybe account linked to the SimpleFIN account maybe_account = Account.create!( family: @family, name: "Checking Account", @@ -332,7 +332,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to accounts_path - # Verify Maybe account still linked to old SimpleFin account (no transfer occurred) + # Verify Maybe account still linked to old SimpleFIN account (no transfer occurred) maybe_account.reload old_simplefin_account.reload assert_equal old_simplefin_account.id, maybe_account.simplefin_account_id @@ -500,4 +500,201 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest q = Rack::Utils.parse_nested_query(uri.query) assert !q.key?("open_relink_for"), "did not expect auto-open when update produced no SFAs/candidates" end + + # Stale account detection and handling tests + + test "setup_accounts detects stale accounts not in upstream API" do + # Create a linked SimpleFIN account + linked_sfa = @simplefin_item.simplefin_accounts.create!( + name: "Old Bitcoin", + account_id: "stale_btc_123", + currency: "USD", + current_balance: 0, + account_type: "crypto" + ) + linked_account = Account.create!( + family: @family, + name: "Old Bitcoin", + balance: 0, + currency: "USD", + accountable: Crypto.create! + ) + linked_sfa.update!(account: linked_account) + linked_account.update!(simplefin_account_id: linked_sfa.id) + + # Set raw_payload to simulate upstream API response WITHOUT the stale account + @simplefin_item.update!(raw_payload: { + accounts: [ + { id: "active_cash_456", name: "Cash", balance: 1000, currency: "USD" } + ] + }) + + get setup_accounts_simplefin_item_url(@simplefin_item) + assert_response :success + + # Should detect the stale account + assert_includes response.body, "Accounts No Longer in SimpleFIN" + assert_includes response.body, "Old Bitcoin" + end + + test "complete_account_setup deletes stale account when delete action selected" do + # Create a linked SimpleFIN account that will be stale + stale_sfa = @simplefin_item.simplefin_accounts.create!( + name: "Stale Account", + account_id: "stale_123", + currency: "USD", + current_balance: 0, + account_type: "depository" + ) + stale_account = Account.create!( + family: @family, + name: "Stale Account", + balance: 0, + currency: "USD", + accountable: Depository.create!(subtype: "checking") + ) + stale_sfa.update!(account: stale_account) + stale_account.update!(simplefin_account_id: stale_sfa.id) + + # Add a transaction to the account + Entry.create!( + account: stale_account, + name: "Test Transaction", + amount: 100, + currency: "USD", + date: Date.today, + entryable: Transaction.create! + ) + + # Set raw_payload without the stale account + @simplefin_item.update!(raw_payload: { accounts: [] }) + + assert_difference [ "Account.count", "SimplefinAccount.count", "Entry.count" ], -1 do + post complete_account_setup_simplefin_item_url(@simplefin_item), params: { + stale_account_actions: { + stale_sfa.id => { action: "delete" } + } + } + end + + assert_redirected_to accounts_path + end + + test "complete_account_setup moves transactions when move action selected" do + # Create source (stale) account + stale_sfa = @simplefin_item.simplefin_accounts.create!( + name: "Bitcoin", + account_id: "stale_btc", + currency: "USD", + current_balance: 0, + account_type: "crypto" + ) + stale_account = Account.create!( + family: @family, + name: "Bitcoin", + balance: 0, + currency: "USD", + accountable: Crypto.create! + ) + stale_sfa.update!(account: stale_account) + stale_account.update!(simplefin_account_id: stale_sfa.id) + + # Create target account (active) + target_sfa = @simplefin_item.simplefin_accounts.create!( + name: "Cash", + account_id: "active_cash", + currency: "USD", + current_balance: 1000, + account_type: "depository" + ) + target_account = Account.create!( + family: @family, + name: "Cash", + balance: 1000, + currency: "USD", + accountable: Depository.create!(subtype: "checking") + ) + target_sfa.update!(account: target_account) + target_account.update!(simplefin_account_id: target_sfa.id) + target_sfa.ensure_account_provider! + + # Add transactions to stale account + entry1 = Entry.create!( + account: stale_account, + name: "P2P Transfer", + amount: 300, + currency: "USD", + date: Date.today, + entryable: Transaction.create! + ) + entry2 = Entry.create!( + account: stale_account, + name: "Another Transfer", + amount: 200, + currency: "USD", + date: Date.today - 1, + entryable: Transaction.create! + ) + + # Set raw_payload with only the target account (stale account missing) + @simplefin_item.update!(raw_payload: { + accounts: [ + { id: "active_cash", name: "Cash", balance: 1000, currency: "USD" } + ] + }) + + # Stale account should be deleted, target account should gain entries + assert_difference "Account.count", -1 do + assert_difference "SimplefinAccount.count", -1 do + post complete_account_setup_simplefin_item_url(@simplefin_item), params: { + stale_account_actions: { + stale_sfa.id => { action: "move", target_account_id: target_account.id } + } + } + end + end + + assert_redirected_to accounts_path + + # Verify transactions were moved to target account + entry1.reload + entry2.reload + assert_equal target_account.id, entry1.account_id + assert_equal target_account.id, entry2.account_id + end + + test "complete_account_setup skips stale account when skip action selected" do + # Create a linked SimpleFIN account that will be stale + stale_sfa = @simplefin_item.simplefin_accounts.create!( + name: "Stale Account", + account_id: "stale_skip", + currency: "USD", + current_balance: 0, + account_type: "depository" + ) + stale_account = Account.create!( + family: @family, + name: "Stale Account", + balance: 0, + currency: "USD", + accountable: Depository.create!(subtype: "checking") + ) + stale_sfa.update!(account: stale_account) + stale_account.update!(simplefin_account_id: stale_sfa.id) + + @simplefin_item.update!(raw_payload: { accounts: [] }) + + assert_no_difference [ "Account.count", "SimplefinAccount.count" ] do + post complete_account_setup_simplefin_item_url(@simplefin_item), params: { + stale_account_actions: { + stale_sfa.id => { action: "skip" } + } + } + end + + assert_redirected_to accounts_path + # Account and SimplefinAccount should still exist + assert Account.exists?(stale_account.id) + assert SimplefinAccount.exists?(stale_sfa.id) + end end