mirror of
https://github.com/we-promise/sure.git
synced 2026-04-21 21:14:17 +00:00
Add stale SimpleFin account detection and improve unlink cleanup (#574)
* Add stale account detection and handling in SimpleFin setup - Introduced UI for managing stale accounts during SimpleFin setup. - Added logic to detect accounts no longer provided by SimpleFin. - Implemented actions to delete, move transactions, or skip stale accounts. - Updated `simplefin_items_controller` with stale account processing and handling. - Enhanced tests to validate stale account scenarios, including detection, deletion, moving transactions, and skipping. * Update SimpleFin to SimpleFIN in locale file Signed-off-by: Juan José Mata <jjmata@jjmata.com> * Silly changes break things ... Signed-off-by: Juan José Mata <jjmata@jjmata.com> * Refactor stale account processing and UI handling - Moved `target_account.sync_later` to execute after commit for proper recalculation of balances. - Added additional safeguard in JavaScript to check for `moveRadioTarget` before updating target visibility. * More silly capitalization changes * Enhance stale account action handling in SimpleFIN setup - Introduced `permitted_stale_account_actions` to validate and permit nested `stale_account_actions` parameters. - Updated `complete_account_setup` to use the new method for safer processing. - Corrected capitalization in SimpleFIN update success and error messages. * Add error tracking and UI feedback for stale account actions - Updated `process_stale_account_actions` to track errors for delete and move actions. - Enhanced UI to display success and error messages for stale account processing. - Implemented destruction of conflicting transfers during account move to maintain data integrity. * Refactor transfer destruction and improve SimpleFIN account setup messages - Updated `simplefin_items_controller` to use `find_each(&:destroy!)` for transfer deletions, ensuring callbacks are invoked. - Enhanced localization for success messages in account creation to handle singular and plural cases. --------- Signed-off-by: Juan José Mata <jjmata@jjmata.com> Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com> Co-authored-by: Juan José Mata <jjmata@jjmata.com>
This commit is contained in:
@@ -141,11 +141,27 @@ class AccountsController < ApplicationController
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
Account.transaction do
|
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)
|
# Remove new system links (account_providers join table)
|
||||||
@account.account_providers.destroy_all
|
@account.account_providers.destroy_all
|
||||||
|
|
||||||
# Remove legacy system links (foreign keys)
|
# Remove legacy system links (foreign keys)
|
||||||
@account.update!(plaid_account_id: nil, simplefin_account_id: nil)
|
@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
|
end
|
||||||
|
|
||||||
redirect_to accounts_path, notice: t("accounts.unlink.success")
|
redirect_to accounts_path, notice: t("accounts.unlink.success")
|
||||||
|
|||||||
@@ -201,17 +201,33 @@ class SimplefinItemsController < ApplicationController
|
|||||||
message: "No additional options needed for Other Assets."
|
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
|
end
|
||||||
|
|
||||||
def complete_account_setup
|
def complete_account_setup
|
||||||
account_types = params[:account_types] || {}
|
account_types = params[:account_types] || {}
|
||||||
account_subtypes = params[:account_subtypes] || {}
|
account_subtypes = params[:account_subtypes] || {}
|
||||||
|
stale_account_actions = permitted_stale_account_actions
|
||||||
|
|
||||||
# Update sync start date from form
|
# Update sync start date from form
|
||||||
if params[:sync_start_date].present?
|
if params[:sync_start_date].present?
|
||||||
@simplefin_item.update!(sync_start_date: params[:sync_start_date])
|
@simplefin_item.update!(sync_start_date: params[:sync_start_date])
|
||||||
end
|
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 account types for this provider (plus Crypto and OtherAsset which SimpleFIN UI allows)
|
||||||
valid_types = Provider::SimplefinAdapter.supported_account_types + [ "Crypto", "OtherAsset" ]
|
valid_types = Provider::SimplefinAdapter.supported_account_types + [ "Crypto", "OtherAsset" ]
|
||||||
|
|
||||||
@@ -275,6 +291,17 @@ class SimplefinItemsController < ApplicationController
|
|||||||
else
|
else
|
||||||
flash[:notice] = t(".no_accounts")
|
flash[:notice] = t(".no_accounts")
|
||||||
end
|
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?
|
if turbo_frame_request?
|
||||||
# Recompute data needed by Accounts#index partials
|
# Recompute data needed by Accounts#index partials
|
||||||
@manual_accounts = Account.uncached {
|
@manual_accounts = Account.uncached {
|
||||||
@@ -451,6 +478,24 @@ class SimplefinItemsController < ApplicationController
|
|||||||
params.require(:simplefin_item).permit(:setup_token, :sync_start_date)
|
params.require(:simplefin_item).permit(:setup_token, :sync_start_date)
|
||||||
end
|
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)
|
def render_error(message, setup_token = nil, context: :new)
|
||||||
if context == :edit
|
if context == :edit
|
||||||
# Keep the persisted record and assign the token for re-render
|
# Keep the persisted record and assign the token for re-render
|
||||||
@@ -472,4 +517,106 @@ class SimplefinItemsController < ApplicationController
|
|||||||
render context, status: :unprocessable_entity
|
render context, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/views/simplefin_items/_stale_account_row.html.erb
Normal file
65
app/views/simplefin_items/_stale_account_row.html.erb
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<% account = simplefin_account.current_account %>
|
||||||
|
<% transaction_count = account&.entries&.where(entryable_type: "Transaction")&.count || 0 %>
|
||||||
|
|
||||||
|
<div class="rounded-lg p-4 border border-warning/50 bg-warning/5 mb-4"
|
||||||
|
data-controller="stale-account-action"
|
||||||
|
data-stale-account-action-account-id-value="<%= simplefin_account.id %>">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-primary">
|
||||||
|
<%= simplefin_account.name %>
|
||||||
|
<% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %>
|
||||||
|
<span class="text-secondary">• <%= simplefin_account.org_data["name"] %></span>
|
||||||
|
<% end %>
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
<%= number_to_currency(simplefin_account.current_balance || 0, unit: simplefin_account.currency || "USD") %>
|
||||||
|
<span class="mx-1">•</span>
|
||||||
|
<%= t("simplefin_items.setup_accounts.stale_accounts.transaction_count", count: transaction_count) %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="space-y-3">
|
||||||
|
<legend class="text-sm text-secondary mb-2">
|
||||||
|
<%= t("simplefin_items.setup_accounts.stale_accounts.action_prompt") %>
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<%= radio_button_tag "stale_account_actions[#{simplefin_account.id}][action]",
|
||||||
|
"delete",
|
||||||
|
false,
|
||||||
|
class: "form-radio accent-primary",
|
||||||
|
data: { action: "change->stale-account-action#updateTargetVisibility" } %>
|
||||||
|
<span class="text-sm text-primary"><%= t("simplefin_items.setup_accounts.stale_accounts.action_delete") %></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<% if target_accounts&.any? %>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<%= radio_button_tag "stale_account_actions[#{simplefin_account.id}][action]",
|
||||||
|
"move",
|
||||||
|
false,
|
||||||
|
class: "form-radio accent-primary",
|
||||||
|
data: { action: "change->stale-account-action#updateTargetVisibility",
|
||||||
|
stale_account_action_target: "moveRadio" } %>
|
||||||
|
<span class="text-sm text-primary"><%= t("simplefin_items.setup_accounts.stale_accounts.action_move") %></span>
|
||||||
|
</label>
|
||||||
|
<%= 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" } %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<%= radio_button_tag "stale_account_actions[#{simplefin_account.id}][action]",
|
||||||
|
"skip",
|
||||||
|
true,
|
||||||
|
class: "form-radio accent-primary",
|
||||||
|
data: { action: "change->stale-account-action#updateTargetVisibility" } %>
|
||||||
|
<span class="text-sm text-primary"><%= t("simplefin_items.setup_accounts.stale_accounts.action_skip") %></span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
@@ -99,6 +99,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if @stale_simplefin_accounts&.any? %>
|
||||||
|
<!-- Stale Accounts Section -->
|
||||||
|
<div class="border-t border-primary mt-6 pt-6">
|
||||||
|
<div class="bg-warning/10 border border-warning rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<%= icon "alert-triangle", size: "sm", class: "text-warning mt-0.5 flex-shrink-0" %>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-primary font-medium mb-1">
|
||||||
|
<%= t("simplefin_items.setup_accounts.stale_accounts.title") %>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-secondary">
|
||||||
|
<%= t("simplefin_items.setup_accounts.stale_accounts.description") %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% @stale_simplefin_accounts.each do |simplefin_account| %>
|
||||||
|
<%= render "stale_account_row", simplefin_account: simplefin_account, target_accounts: @target_accounts %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
|
|||||||
@@ -2,43 +2,60 @@
|
|||||||
en:
|
en:
|
||||||
simplefin_items:
|
simplefin_items:
|
||||||
new:
|
new:
|
||||||
title: Connect SimpleFin
|
title: Connect SimpleFIN
|
||||||
setup_token: Setup token
|
setup_token: Setup token
|
||||||
setup_token_placeholder: paste your SimpleFin setup token
|
setup_token_placeholder: paste your SimpleFIN setup token
|
||||||
connect: Connect
|
connect: Connect
|
||||||
cancel: Cancel
|
cancel: Cancel
|
||||||
create:
|
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:
|
errors:
|
||||||
blank_token: Please enter a SimpleFin setup token.
|
blank_token: Please enter a SimpleFIN setup token.
|
||||||
invalid_token: Invalid setup token. Please check that you copied the complete token from SimpleFin Bridge.
|
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.
|
token_compromised: The setup token may be compromised, expired, or already used. Please create a new one.
|
||||||
create_failed: "Failed to connect: %{message}"
|
create_failed: "Failed to connect: %{message}"
|
||||||
unexpected: An unexpected error occurred. Please try again or contact support.
|
unexpected: An unexpected error occurred. Please try again or contact support.
|
||||||
destroy:
|
destroy:
|
||||||
success: SimpleFin connection will be removed
|
success: SimpleFIN connection will be removed
|
||||||
update:
|
update:
|
||||||
success: SimpleFin connection updated successfully! Your accounts are being reconnected.
|
success: SimpleFIN connection updated successfully! Your accounts are being reconnected.
|
||||||
errors:
|
errors:
|
||||||
blank_token: Please enter a SimpleFin setup token.
|
blank_token: Please enter a SimpleFIN setup token.
|
||||||
invalid_token: Invalid setup token. Please check that you copied the complete token from SimpleFin Bridge.
|
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.
|
token_compromised: The setup token may be compromised, expired, or already used. Please create a new one.
|
||||||
update_failed: "Failed to update connection: %{message}"
|
update_failed: "Failed to update connection: %{message}"
|
||||||
unexpected: An unexpected error occurred. Please try again or contact support.
|
unexpected: An unexpected error occurred. Please try again or contact support.
|
||||||
edit:
|
edit:
|
||||||
setup_token:
|
setup_token:
|
||||||
label: "SimpleFin Setup Token:"
|
label: "SimpleFIN Setup Token:"
|
||||||
placeholder: "Paste your SimpleFin setup token here..."
|
placeholder: "Paste your SimpleFIN setup token here..."
|
||||||
help_text: "The token should be a long string starting with letters and numbers"
|
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:
|
complete_account_setup:
|
||||||
all_skipped: "All accounts were skipped. No accounts were created."
|
all_skipped: "All accounts were skipped. No accounts were created."
|
||||||
no_accounts: "No accounts to set up."
|
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:
|
simplefin_item:
|
||||||
add_new: Add new connection
|
add_new: Add new connection
|
||||||
confirm_accept: Delete connection
|
confirm_accept: Delete connection
|
||||||
confirm_body: This will permanently delete all the accounts in this group and all associated data.
|
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
|
delete: Delete
|
||||||
deletion_in_progress: "(deletion in progress...)"
|
deletion_in_progress: "(deletion in progress...)"
|
||||||
error: Error occurred while syncing data
|
error: Error occurred while syncing data
|
||||||
@@ -46,7 +63,7 @@ en:
|
|||||||
no_accounts_title: No accounts found
|
no_accounts_title: No accounts found
|
||||||
requires_update: Reconnect
|
requires_update: Reconnect
|
||||||
setup_needed: New accounts ready to set up
|
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
|
setup_action: Set Up New Accounts
|
||||||
status: Last synced %{timestamp} ago
|
status: Last synced %{timestamp} ago
|
||||||
status_never: Never synced
|
status_never: Never synced
|
||||||
@@ -68,4 +85,4 @@ en:
|
|||||||
success: Account successfully linked to SimpleFIN
|
success: Account successfully linked to SimpleFIN
|
||||||
errors:
|
errors:
|
||||||
only_manual: Only manual accounts can be linked
|
only_manual: Only manual accounts can be linked
|
||||||
invalid_simplefin_account: Invalid SimpleFIN account selected
|
invalid_simplefin_account: Invalid SimpleFIN account selected
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
en:
|
en:
|
||||||
simplefin_items:
|
simplefin_items:
|
||||||
update:
|
update:
|
||||||
success: "SimpleFin connection updated."
|
success: "SimpleFIN connection updated."
|
||||||
errors:
|
errors:
|
||||||
blank_token: "Missing SimpleFin access token. Please provide a token or use Link Existing Accounts to proceed."
|
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}"
|
update_failed: "Failed to update SimpleFIN connection: %{message}"
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert_redirected_to accounts_path
|
assert_redirected_to accounts_path
|
||||||
assert_equal "SimpleFin connection updated.", flash[:notice]
|
assert_equal "SimpleFIN connection updated.", flash[:notice]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should handle update with invalid token" do
|
test "should handle update with invalid token" do
|
||||||
@@ -179,7 +179,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert_response :unprocessable_entity
|
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
|
end
|
||||||
|
|
||||||
test "should transfer accounts when updating simplefin item token" do
|
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")
|
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!(
|
old_simplefin_account1 = @simplefin_item.simplefin_accounts.create!(
|
||||||
name: "Test Checking",
|
name: "Test Checking",
|
||||||
account_id: "sf_account_123",
|
account_id: "sf_account_123",
|
||||||
@@ -203,7 +203,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
account_type: "depository"
|
account_type: "depository"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create Maybe accounts linked to the SimpleFin accounts
|
# Create Maybe accounts linked to the SimpleFIN accounts
|
||||||
maybe_account1 = Account.create!(
|
maybe_account1 = Account.create!(
|
||||||
family: @family,
|
family: @family,
|
||||||
name: "Checking Account",
|
name: "Checking Account",
|
||||||
@@ -223,7 +223,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
simplefin_account_id: old_simplefin_account2.id
|
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_account1.update!(account: maybe_account1)
|
||||||
old_simplefin_account2.update!(account: maybe_account2)
|
old_simplefin_account2.update!(account: maybe_account2)
|
||||||
|
|
||||||
@@ -261,31 +261,31 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
assert_redirected_to accounts_path
|
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_account1.id), "maybe_account1 should still exist"
|
||||||
assert Account.exists?(maybe_account2.id), "maybe_account2 should still exist"
|
assert Account.exists?(maybe_account2.id), "maybe_account2 should still exist"
|
||||||
|
|
||||||
maybe_account1.reload
|
maybe_account1.reload
|
||||||
maybe_account2.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
|
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_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")
|
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_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_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_account1.id, maybe_account1.simplefin_account_id
|
||||||
assert_equal new_sf_account2.id, maybe_account2.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.
|
# 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
|
@simplefin_item.reload
|
||||||
assert @simplefin_item.scheduled_for_deletion?
|
assert @simplefin_item.scheduled_for_deletion?
|
||||||
end
|
end
|
||||||
@@ -295,7 +295,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
token = Base64.strict_encode64("https://example.com/claim")
|
token = Base64.strict_encode64("https://example.com/claim")
|
||||||
|
|
||||||
# Create old SimpleFin account
|
# Create old SimpleFIN account
|
||||||
old_simplefin_account = @simplefin_item.simplefin_accounts.create!(
|
old_simplefin_account = @simplefin_item.simplefin_accounts.create!(
|
||||||
name: "Test Checking",
|
name: "Test Checking",
|
||||||
account_id: "sf_account_123",
|
account_id: "sf_account_123",
|
||||||
@@ -304,7 +304,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
account_type: "depository"
|
account_type: "depository"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create Maybe account linked to the SimpleFin account
|
# Create Maybe account linked to the SimpleFIN account
|
||||||
maybe_account = Account.create!(
|
maybe_account = Account.create!(
|
||||||
family: @family,
|
family: @family,
|
||||||
name: "Checking Account",
|
name: "Checking Account",
|
||||||
@@ -332,7 +332,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
assert_redirected_to accounts_path
|
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
|
maybe_account.reload
|
||||||
old_simplefin_account.reload
|
old_simplefin_account.reload
|
||||||
assert_equal old_simplefin_account.id, maybe_account.simplefin_account_id
|
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)
|
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"
|
assert !q.key?("open_relink_for"), "did not expect auto-open when update produced no SFAs/candidates"
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user