mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 22:34:47 +00:00
- Removed the `errors` modal and its associated view. - Eliminated references to `errors` route and controller methods. - Consolidated error handling into the `register_error` method to improve error tracking and de-duplication. - Enhanced logging and introduced instrumentation for better observability. Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
507 lines
18 KiB
Ruby
507 lines
18 KiB
Ruby
class SimplefinItemsController < ApplicationController
|
|
include SimplefinItems::MapsHelper
|
|
before_action :set_simplefin_item, only: [ :show, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup ]
|
|
|
|
def index
|
|
@simplefin_items = Current.family.simplefin_items.active.ordered
|
|
render layout: "settings"
|
|
end
|
|
|
|
def show
|
|
end
|
|
|
|
def edit
|
|
# For SimpleFin, editing means providing a new setup token to replace expired access
|
|
@simplefin_item.setup_token = nil # Clear any existing setup token
|
|
end
|
|
|
|
def update
|
|
setup_token = simplefin_params[:setup_token]
|
|
|
|
return render_error(t(".errors.blank_token"), context: :edit) if setup_token.blank?
|
|
|
|
begin
|
|
# Create new SimpleFin item data with updated token
|
|
updated_item = Current.family.create_simplefin_item!(
|
|
setup_token: setup_token,
|
|
item_name: @simplefin_item.name
|
|
)
|
|
|
|
# Ensure new simplefin_accounts are created & have account_id set
|
|
updated_item.import_latest_simplefin_data
|
|
|
|
# Transfer accounts from old item to new item
|
|
ActiveRecord::Base.transaction do
|
|
@simplefin_item.simplefin_accounts.each do |old_account|
|
|
if old_account.account.present?
|
|
# Find matching account in new item by account_id
|
|
new_account = updated_item.simplefin_accounts.find_by(account_id: old_account.account_id)
|
|
if new_account
|
|
# Transfer the account directly to the new SimpleFin account
|
|
# This will automatically break the old association
|
|
old_account.account.update!(simplefin_account_id: new_account.id)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Mark old item for deletion
|
|
@simplefin_item.destroy_later
|
|
end
|
|
|
|
# Clear any requires_update status on new item
|
|
updated_item.update!(status: :good)
|
|
|
|
if turbo_frame_request?
|
|
@simplefin_items = Current.family.simplefin_items.ordered
|
|
render turbo_stream: turbo_stream.replace(
|
|
"simplefin-providers-panel",
|
|
partial: "settings/providers/simplefin_panel",
|
|
locals: { simplefin_items: @simplefin_items }
|
|
)
|
|
else
|
|
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
|
end
|
|
rescue ArgumentError, URI::InvalidURIError
|
|
render_error(t(".errors.invalid_token"), setup_token, context: :edit)
|
|
rescue Provider::Simplefin::SimplefinError => e
|
|
error_message = case e.error_type
|
|
when :token_compromised
|
|
t(".errors.token_compromised")
|
|
else
|
|
t(".errors.update_failed", message: e.message)
|
|
end
|
|
render_error(error_message, setup_token, context: :edit)
|
|
rescue => e
|
|
Rails.logger.error("SimpleFin connection update error: #{e.message}")
|
|
render_error(t(".errors.unexpected"), setup_token, context: :edit)
|
|
end
|
|
end
|
|
|
|
def new
|
|
@simplefin_item = Current.family.simplefin_items.build
|
|
end
|
|
|
|
def create
|
|
setup_token = simplefin_params[:setup_token]
|
|
|
|
return render_error(t(".errors.blank_token")) if setup_token.blank?
|
|
|
|
begin
|
|
@simplefin_item = Current.family.create_simplefin_item!(
|
|
setup_token: setup_token,
|
|
item_name: "SimpleFIN Connection"
|
|
)
|
|
|
|
if turbo_frame_request?
|
|
flash.now[:notice] = t(".success")
|
|
@simplefin_items = Current.family.simplefin_items.ordered
|
|
render turbo_stream: [
|
|
turbo_stream.replace(
|
|
"simplefin-providers-panel",
|
|
partial: "settings/providers/simplefin_panel",
|
|
locals: { simplefin_items: @simplefin_items }
|
|
),
|
|
*flash_notification_stream_items
|
|
]
|
|
else
|
|
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
|
end
|
|
rescue ArgumentError, URI::InvalidURIError
|
|
render_error(t(".errors.invalid_token"), setup_token)
|
|
rescue Provider::Simplefin::SimplefinError => e
|
|
error_message = case e.error_type
|
|
when :token_compromised
|
|
t(".errors.token_compromised")
|
|
else
|
|
t(".errors.create_failed", message: e.message)
|
|
end
|
|
render_error(error_message, setup_token)
|
|
rescue => e
|
|
Rails.logger.error("SimpleFin connection error: #{e.message}")
|
|
render_error(t(".errors.unexpected"), setup_token)
|
|
end
|
|
end
|
|
|
|
def destroy
|
|
# Ensure we detach provider links and legacy associations before scheduling deletion
|
|
begin
|
|
@simplefin_item.unlink_all!(dry_run: false)
|
|
rescue => e
|
|
Rails.logger.warn("SimpleFin unlink during destroy failed: #{e.class} - #{e.message}")
|
|
end
|
|
@simplefin_item.destroy_later
|
|
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
|
end
|
|
|
|
def sync
|
|
unless @simplefin_item.syncing?
|
|
@simplefin_item.sync_later
|
|
end
|
|
|
|
respond_to do |format|
|
|
format.html { redirect_back_or_to accounts_path }
|
|
format.json { head :ok }
|
|
end
|
|
end
|
|
|
|
# Starts a balances-only sync for this SimpleFin item
|
|
def balances
|
|
sync = @simplefin_item.syncs.create!(status: :pending, sync_stats: { "balances_only" => true })
|
|
SimplefinItem::Syncer.new(@simplefin_item).perform_sync(sync)
|
|
|
|
respond_to do |format|
|
|
format.html { redirect_back_or_to accounts_path }
|
|
format.json { render json: { ok: true, sync_id: sync.id } }
|
|
end
|
|
end
|
|
|
|
def setup_accounts
|
|
@simplefin_accounts = @simplefin_item.simplefin_accounts.includes(:account).where(accounts: { id: nil })
|
|
@account_type_options = [
|
|
[ "Checking or Savings Account", "Depository" ],
|
|
[ "Credit Card", "CreditCard" ],
|
|
[ "Investment Account", "Investment" ],
|
|
[ "Loan or Mortgage", "Loan" ],
|
|
[ "Other Asset", "OtherAsset" ]
|
|
]
|
|
|
|
# Subtype options for each account type
|
|
@subtype_options = {
|
|
"Depository" => {
|
|
label: "Account Subtype:",
|
|
options: Depository::SUBTYPES.map { |k, v| [ v[:long], k ] }
|
|
},
|
|
"CreditCard" => {
|
|
label: "",
|
|
options: [],
|
|
message: "Credit cards will be automatically set up as credit card accounts."
|
|
},
|
|
"Investment" => {
|
|
label: "Investment Type:",
|
|
options: Investment::SUBTYPES.map { |k, v| [ v[:long], k ] }
|
|
},
|
|
"Loan" => {
|
|
label: "Loan Type:",
|
|
options: Loan::SUBTYPES.map { |k, v| [ v[:long], k ] }
|
|
},
|
|
"OtherAsset" => {
|
|
label: nil,
|
|
options: [],
|
|
message: "No additional options needed for Other Assets."
|
|
}
|
|
}
|
|
end
|
|
|
|
def complete_account_setup
|
|
account_types = params[:account_types] || {}
|
|
account_subtypes = params[:account_subtypes] || {}
|
|
|
|
# Update sync start date from form
|
|
if params[:sync_start_date].present?
|
|
@simplefin_item.update!(sync_start_date: params[:sync_start_date])
|
|
end
|
|
|
|
account_types.each do |simplefin_account_id, selected_type|
|
|
simplefin_account = @simplefin_item.simplefin_accounts.find(simplefin_account_id)
|
|
selected_subtype = account_subtypes[simplefin_account_id]
|
|
|
|
# Default subtype for CreditCard since it only has one option
|
|
selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank?
|
|
|
|
# Create account with user-selected type and subtype
|
|
account = Account.create_from_simplefin_account(
|
|
simplefin_account,
|
|
selected_type,
|
|
selected_subtype
|
|
)
|
|
simplefin_account.update!(account: account)
|
|
end
|
|
|
|
# Clear pending status and mark as complete
|
|
@simplefin_item.update!(pending_account_setup: false)
|
|
|
|
# Trigger a sync to process the imported SimpleFin data (transactions and holdings)
|
|
@simplefin_item.sync_later
|
|
|
|
flash[:notice] = t(".success")
|
|
if turbo_frame_request?
|
|
# Recompute data needed by Accounts#index partials
|
|
@manual_accounts = Account.uncached {
|
|
Current.family.accounts
|
|
.visible_manual
|
|
.order(:name)
|
|
.to_a
|
|
}
|
|
@simplefin_items = Current.family.simplefin_items.ordered.includes(:syncs)
|
|
build_simplefin_maps_for(@simplefin_items)
|
|
|
|
manual_accounts_stream = if @manual_accounts.any?
|
|
turbo_stream.update(
|
|
"manual-accounts",
|
|
partial: "accounts/index/manual_accounts",
|
|
locals: { accounts: @manual_accounts }
|
|
)
|
|
else
|
|
turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts"))
|
|
end
|
|
|
|
render turbo_stream: [
|
|
manual_accounts_stream,
|
|
turbo_stream.replace(
|
|
ActionView::RecordIdentifier.dom_id(@simplefin_item),
|
|
partial: "simplefin_items/simplefin_item",
|
|
locals: { simplefin_item: @simplefin_item }
|
|
)
|
|
] + Array(flash_notification_stream_items)
|
|
else
|
|
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
|
end
|
|
end
|
|
|
|
def select_existing_account
|
|
@account = Current.family.accounts.find(params[:account_id])
|
|
|
|
# Filter out SimpleFIN accounts that are already linked to any account
|
|
# (either via account_provider or legacy account association)
|
|
@available_simplefin_accounts = Current.family.simplefin_items
|
|
.includes(:simplefin_accounts)
|
|
.flat_map(&:simplefin_accounts)
|
|
.reject { |sfa| sfa.account_provider.present? || sfa.account.present? }
|
|
.sort_by { |sfa| sfa.updated_at || sfa.created_at }
|
|
.reverse
|
|
|
|
# Always render a modal: either choices or a helpful empty-state
|
|
render :select_existing_account, layout: false
|
|
end
|
|
|
|
def link_existing_account
|
|
@account = Current.family.accounts.find(params[:account_id])
|
|
simplefin_account = SimplefinAccount.find(params[:simplefin_account_id])
|
|
|
|
# Guard: only manual accounts can be linked (no existing provider links or legacy IDs)
|
|
if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present?
|
|
flash[:alert] = "Only manual accounts can be linked"
|
|
if turbo_frame_request?
|
|
return render turbo_stream: Array(flash_notification_stream_items)
|
|
else
|
|
return redirect_to account_path(@account), alert: flash[:alert]
|
|
end
|
|
end
|
|
|
|
# Verify the SimpleFIN account belongs to this family's SimpleFIN items
|
|
unless Current.family.simplefin_items.include?(simplefin_account.simplefin_item)
|
|
flash[:alert] = "Invalid SimpleFIN account selected"
|
|
if turbo_frame_request?
|
|
render turbo_stream: Array(flash_notification_stream_items)
|
|
else
|
|
redirect_to account_path(@account), alert: flash[:alert]
|
|
end
|
|
return
|
|
end
|
|
|
|
# Relink behavior: detach any legacy link and point provider link at the chosen account
|
|
Account.transaction do
|
|
simplefin_account.lock!
|
|
# Clear legacy association if present
|
|
if simplefin_account.account_id.present?
|
|
simplefin_account.update!(account_id: nil)
|
|
end
|
|
|
|
# Upsert the AccountProvider mapping deterministically
|
|
ap = AccountProvider.find_or_initialize_by(provider: simplefin_account)
|
|
previous_account = ap.account
|
|
ap.account_id = @account.id
|
|
ap.save!
|
|
|
|
# If the provider was previously linked to a different account in this family,
|
|
# and that account is now orphaned, quietly disable it so it disappears from the
|
|
# visible manual list. This mirrors the unified flow expectation that the provider
|
|
# follows the chosen account.
|
|
if previous_account && previous_account.id != @account.id && previous_account.family_id == @account.family_id
|
|
previous_account.disable! rescue nil
|
|
end
|
|
end
|
|
|
|
if turbo_frame_request?
|
|
# Reload the item to ensure associations are fresh
|
|
simplefin_account.reload
|
|
item = simplefin_account.simplefin_item
|
|
item.reload
|
|
|
|
# Recompute data needed by Accounts#index partials
|
|
@manual_accounts = Account.uncached {
|
|
Current.family.accounts
|
|
.visible_manual
|
|
.order(:name)
|
|
.to_a
|
|
}
|
|
@simplefin_items = Current.family.simplefin_items.ordered.includes(:syncs)
|
|
build_simplefin_maps_for(@simplefin_items)
|
|
|
|
flash[:notice] = "Account successfully linked to SimpleFIN"
|
|
@account.reload
|
|
manual_accounts_stream = if @manual_accounts.any?
|
|
turbo_stream.update(
|
|
"manual-accounts",
|
|
partial: "accounts/index/manual_accounts",
|
|
locals: { accounts: @manual_accounts }
|
|
)
|
|
else
|
|
turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts"))
|
|
end
|
|
|
|
render turbo_stream: [
|
|
# Optimistic removal of the specific account row if it exists in the DOM
|
|
turbo_stream.remove(ActionView::RecordIdentifier.dom_id(@account)),
|
|
manual_accounts_stream,
|
|
turbo_stream.replace(
|
|
ActionView::RecordIdentifier.dom_id(item),
|
|
partial: "simplefin_items/simplefin_item",
|
|
locals: { simplefin_item: item }
|
|
),
|
|
turbo_stream.replace("modal", view_context.turbo_frame_tag("modal"))
|
|
] + Array(flash_notification_stream_items)
|
|
else
|
|
redirect_to accounts_path(cache_bust: SecureRandom.hex(6)), notice: "Account successfully linked to SimpleFIN", status: :see_other
|
|
end
|
|
end
|
|
|
|
|
|
private
|
|
|
|
NAME_NORM_RE = /\s+/.freeze
|
|
|
|
|
|
def normalize_name(str)
|
|
s = str.to_s.downcase.strip
|
|
return s if s.empty?
|
|
s.gsub(NAME_NORM_RE, " ")
|
|
end
|
|
|
|
def compute_relink_candidates
|
|
# Best-effort dedup before building candidates
|
|
@simplefin_item.dedup_simplefin_accounts! rescue nil
|
|
|
|
family = @simplefin_item.family
|
|
manuals = Account.visible_manual.where(family_id: family.id).to_a
|
|
|
|
# Evaluate only one SimpleFin account per upstream account_id (prefer linked, else newest)
|
|
grouped = @simplefin_item.simplefin_accounts.group_by(&:account_id)
|
|
sfas = grouped.values.map { |list| list.find { |s| s.current_account.present? } || list.max_by(&:updated_at) }
|
|
|
|
Rails.logger.info("SimpleFin compute_relink_candidates: manuals=#{manuals.size} sfas=#{sfas.size} (item_id=#{@simplefin_item.id})")
|
|
|
|
used_manual_ids = Set.new
|
|
pairs = []
|
|
|
|
sfas.each do |sfa|
|
|
next if sfa.name.blank?
|
|
# Heuristics (with ambiguity guards): last4 > balance ±0.01 > name
|
|
raw = (sfa.raw_payload || {}).with_indifferent_access
|
|
sfa_last4 = raw[:mask] || raw[:last4] || raw[:"last-4"] || raw[:"account_number_last4"]
|
|
sfa_last4 = sfa_last4.to_s.strip.presence
|
|
sfa_balance = (sfa.current_balance || sfa.available_balance).to_d rescue 0.to_d
|
|
|
|
chosen = nil
|
|
reason = nil
|
|
|
|
# 1) last4 match: compute all candidates not yet used
|
|
if sfa_last4.present?
|
|
last4_matches = manuals.reject { |a| used_manual_ids.include?(a.id) }.select do |a|
|
|
a_last4 = nil
|
|
%i[mask last4 number_last4 account_number_last4].each do |k|
|
|
if a.respond_to?(k)
|
|
val = a.public_send(k)
|
|
a_last4 = val.to_s.strip.presence if val.present?
|
|
break if a_last4
|
|
end
|
|
end
|
|
a_last4.present? && a_last4 == sfa_last4
|
|
end
|
|
# Ambiguity guard: skip if multiple matches
|
|
if last4_matches.size == 1
|
|
cand = last4_matches.first
|
|
# Conflict guard: if both have balances and differ wildly, skip
|
|
begin
|
|
ab = (cand.balance || cand.cash_balance || 0).to_d
|
|
if sfa_balance.nonzero? && ab.nonzero? && (ab - sfa_balance).abs > BigDecimal("1.00")
|
|
cand = nil
|
|
end
|
|
rescue
|
|
# ignore balance parsing errors
|
|
end
|
|
if cand
|
|
chosen = cand
|
|
reason = "last4"
|
|
end
|
|
end
|
|
end
|
|
|
|
# 2) balance proximity
|
|
if chosen.nil? && sfa_balance.nonzero?
|
|
balance_matches = manuals.reject { |a| used_manual_ids.include?(a.id) }.select do |a|
|
|
begin
|
|
ab = (a.balance || a.cash_balance || 0).to_d
|
|
(ab - sfa_balance).abs <= BigDecimal("0.01")
|
|
rescue
|
|
false
|
|
end
|
|
end
|
|
if balance_matches.size == 1
|
|
chosen = balance_matches.first
|
|
reason = "balance"
|
|
end
|
|
end
|
|
|
|
# 3) exact normalized name
|
|
if chosen.nil?
|
|
name_matches = manuals.reject { |a| used_manual_ids.include?(a.id) }.select { |a| normalize_name(a.name) == normalize_name(sfa.name) }
|
|
if name_matches.size == 1
|
|
chosen = name_matches.first
|
|
reason = "name"
|
|
end
|
|
end
|
|
|
|
if chosen
|
|
used_manual_ids << chosen.id
|
|
pairs << { sfa_id: sfa.id, sfa_name: sfa.name, manual_id: chosen.id, manual_name: chosen.name, reason: reason }
|
|
end
|
|
end
|
|
|
|
Rails.logger.info("SimpleFin compute_relink_candidates: built #{pairs.size} pairs (item_id=#{@simplefin_item.id})")
|
|
|
|
# Return without the reason field to the view
|
|
pairs.map { |p| p.slice(:sfa_id, :sfa_name, :manual_id, :manual_name) }
|
|
end
|
|
|
|
def set_simplefin_item
|
|
@simplefin_item = Current.family.simplefin_items.find(params[:id])
|
|
end
|
|
|
|
def simplefin_params
|
|
params.require(:simplefin_item).permit(:setup_token, :sync_start_date)
|
|
end
|
|
|
|
def render_error(message, setup_token = nil, context: :new)
|
|
if context == :edit
|
|
# Keep the persisted record and assign the token for re-render
|
|
@simplefin_item.setup_token = setup_token if @simplefin_item
|
|
else
|
|
@simplefin_item = Current.family.simplefin_items.build(setup_token: setup_token)
|
|
end
|
|
@error_message = message
|
|
|
|
if turbo_frame_request?
|
|
# Re-render the SimpleFIN providers panel in place to avoid "Content missing"
|
|
@simplefin_items = Current.family.simplefin_items.ordered
|
|
render turbo_stream: turbo_stream.replace(
|
|
"simplefin-providers-panel",
|
|
partial: "settings/providers/simplefin_panel",
|
|
locals: { simplefin_items: @simplefin_items }
|
|
), status: :unprocessable_entity
|
|
else
|
|
render context, status: :unprocessable_entity
|
|
end
|
|
end
|
|
end
|