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) %>
+
+
+
+
+
+
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