Files
sure/app/controllers/sophtron_items_controller.rb
Juan José Mata 81cdccb768 [codex] Complete Sophtron account mapping (#1698)
* Complete Sophtron account mapping

* Clarify Sophtron login challenge flow

* Add Sophtron connection UI timeout

* Treat Sophtron timeout jobs as failed

* Reset failed Sophtron connection state

* Handle stale Sophtron connection jobs

* Advance Sophtron polling timeout

* Shorten Sophtron connection timeout

* Fix Sophtron modal polling updates

* Stabilize Sophtron MFA polling

* Give Sophtron OTP challenges more time

* Clarify Sophtron institution login failures

* Extend Sophtron polling during login progress

* Probe Sophtron accounts after completed MFA step

* Align Sophtron dialogs with design system

* Start Sophtron initial load after linking accounts

* Fix Sophtron initial transaction load

* Fail Sophtron sync without institution connection

* Fix tests

* Wrap Sophtron account linking in transaction

* Wrap Sophtron provider responses

* Fix Sophtron MFA security tests

* Guard Sophtron MFA challenge arrays

* Respect Sophtron initial load window

* Use unique Sophtron MFA answer field ids

* Address Sophtron review follow-ups

* Fix Sophtron transaction sync refresh

* Avoid blocking Sophtron refresh polling

* Move Sophtron account helpers to model

* Keep Sophtron grouping provider-level

* Start new Sophtron institution links

* Isolate Sophtron institution connections

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-08 15:15:23 +02:00

915 lines
32 KiB
Ruby

class SophtronItemsController < ApplicationController
CONNECTION_STATUS_MAX_POLLS = 6
LOGIN_PROGRESS_CONNECTION_STATUS_MAX_POLLS = 15
POST_MFA_CONNECTION_STATUS_MAX_POLLS = 15
CONNECTION_STATUS_POLL_INTERVAL_MS = 4_000
MAX_SECURITY_ANSWERS = 10
MAX_SECURITY_ANSWER_LENGTH = 256
before_action :set_sophtron_item, only: [
:show, :edit, :update, :destroy, :connect_institution, :sync,
:connection_status, :submit_mfa,
:setup_accounts, :complete_account_setup
]
before_action :require_admin!, only: [
:new, :create, :preload_accounts, :select_accounts, :link_accounts,
:select_existing_account, :link_existing_account, :connect_institution,
:edit, :update, :destroy, :sync, :connection_status, :submit_mfa,
:setup_accounts, :complete_account_setup
]
def index
@sophtron_items = Current.family.sophtron_items.active.ordered
render layout: "settings"
end
def show
end
def preload_accounts
item = configured_sophtron_item
unless item
render json: { success: false, error: "no_credentials_configured", has_accounts: false }
return
end
item.ensure_customer!
unless item.connected_to_institution?
render json: { success: false, error: "no_institution_connected", has_accounts: nil }
return
end
accounts = item.fetch_remote_accounts
render json: { success: true, has_accounts: accounts.any?, cached: true }
rescue Provider::Sophtron::Error => e
Rails.logger.error("Sophtron preload error: #{e.message}")
render json: { success: false, error: "api_error", error_message: t(".api_error"), has_accounts: nil }
rescue StandardError => e
Rails.logger.error("Unexpected error preloading Sophtron accounts: #{e.class}: #{e.message}")
render json: { success: false, error: "unexpected_error", error_message: t(".unexpected_error"), has_accounts: nil }
end
def select_accounts
item = configured_sophtron_item
unless item
render_or_redirect_setup_required
return
end
item.ensure_customer!
if connect_new_institution_flow? || !item.connected_to_institution?
prepare_connection_form(item)
render :connect, layout: false
return
end
@available_accounts = item.reject_already_linked(item.fetch_remote_accounts)
@accountable_type = params[:accountable_type] || "Depository"
@return_to = safe_return_to_path
if @available_accounts.empty?
redirect_to new_account_path, alert: t(".no_accounts_found")
return
end
render layout: false
rescue Provider::Sophtron::Error => e
Rails.logger.error("Sophtron API error in select_accounts: #{e.message}")
render_api_error(t(".api_error"), safe_return_to_path)
rescue StandardError => e
Rails.logger.error("Unexpected error in select_accounts: #{e.class}: #{e.message}")
render_api_error(t(".unexpected_error"), safe_return_to_path)
end
def connect_institution
if params[:institution_id].blank? || params[:bank_username].blank? || params[:bank_password].blank?
redirect_to select_accounts_sophtron_items_path(connection_context_params), alert: t(".missing_parameters")
return
end
item = item_for_institution_connection(@sophtron_item)
item.ensure_customer!
response = sophtron_response_data!(
item.sophtron_provider.create_user_institution(
institution_id: params[:institution_id],
username: params[:bank_username],
password: params[:bank_password],
pin: ""
)
).with_indifferent_access
job_id = response[:JobID] || response[:job_id]
user_institution_id = response[:UserInstitutionID] || response[:user_institution_id]
if job_id.blank? || user_institution_id.blank?
raise Provider::Sophtron::Error.new("Sophtron did not return JobID and UserInstitutionID", :invalid_response)
end
item.update!(
name: item.name.presence || t("sophtron_items.defaults.name"),
institution_id: params[:institution_id],
institution_name: params[:institution_name],
user_institution_id: user_institution_id,
current_job_id: job_id,
raw_job_payload: response,
job_status: nil,
last_connection_error: nil,
status: :good
)
redirect_to connection_status_sophtron_item_path(item, connection_context_params)
rescue Provider::Sophtron::Error => e
Rails.logger.error("Sophtron connect institution error: #{e.message}")
redirect_to select_accounts_sophtron_items_path(connection_context_params), alert: t(".api_error", message: e.message)
end
def connection_status
if prefetch_request?
head :no_content
return
end
if @sophtron_item.current_job_id.blank?
redirect_to select_accounts_sophtron_items_path(connection_context_params)
return
end
@poll_attempt = requested_poll_attempt
if @poll_attempt > connection_status_max_polls
render_connection_timeout
return
end
job = sophtron_response_data!(@sophtron_item.sophtron_provider.get_job_information(@sophtron_item.current_job_id))
@sophtron_item.upsert_job_snapshot!(job)
if Provider::Sophtron.job_success?(job)
@sophtron_item.update!(
current_job_id: nil,
last_connection_error: nil,
pending_account_setup: true,
status: :good
)
render_account_selection(@sophtron_item, force_refresh: true)
elsif Provider::Sophtron.job_requires_input?(job)
@challenge = @sophtron_item.build_mfa_challenge(job)
prepare_connection_status_context
render :mfa, layout: false
elsif post_mfa_polling? && Provider::Sophtron.job_completed?(job)
return if render_account_selection_if_accounts_available(@sophtron_item)
render_pending_connection_status
elsif Provider::Sophtron.job_failed?(job)
failure_message = sophtron_connection_failure_message(job)
@sophtron_item.update!(
current_job_id: nil,
user_institution_id: nil,
last_connection_error: failure_message,
status: :requires_update
)
render_institution_connection_error(failure_message)
else
render_pending_connection_status
end
rescue Provider::Sophtron::Error => e
Rails.logger.error("Sophtron job polling error: #{e.message}")
render_api_error(t(".api_error", message: e.message), accounts_path)
end
def submit_mfa
provider = @sophtron_item.sophtron_provider
job_id = @sophtron_item.current_job_id
case params[:mfa_type]
when "security_answer"
security_answers = normalized_security_answers
unless security_answers
redirect_to connection_status_sophtron_item_path(@sophtron_item, connection_context_params), alert: t(".invalid_security_answers")
return
end
sophtron_response_data!(provider.update_job_security_answer(job_id, security_answers))
when "token_choice"
sophtron_response_data!(provider.update_job_token_input(job_id, token_choice: params[:token_choice]))
when "token_input"
sophtron_response_data!(provider.update_job_token_input(job_id, token_input: params[:token_input]))
when "verify_phone"
sophtron_response_data!(provider.update_job_token_input(job_id, verify_phone_flag: true))
when "captcha"
sophtron_response_data!(provider.update_job_captcha(job_id, params[:captcha_input]))
else
redirect_to connection_status_sophtron_item_path(@sophtron_item, connection_context_params), alert: t(".unknown_challenge")
return
end
redirect_to connection_status_sophtron_item_path(@sophtron_item, connection_context_params.merge(post_mfa: true))
rescue Provider::Sophtron::Error => e
Rails.logger.error("Sophtron MFA submission error: #{e.message}")
redirect_to connection_status_sophtron_item_path(@sophtron_item, connection_context_params), alert: t(".api_error", message: e.message)
end
def link_accounts
selected_account_ids = params[:account_ids] || []
accountable_type = params[:accountable_type] || "Depository"
return_to = safe_return_to_path
if selected_account_ids.empty?
redirect_to new_account_path, alert: t(".no_accounts_selected")
return
end
item = configured_sophtron_item
unless item&.connected_to_institution?
redirect_to select_accounts_sophtron_items_path(accountable_type: accountable_type, return_to: return_to), alert: t(".no_institution_connected")
return
end
accounts_data = item.fetch_remote_accounts(force: true)
created_accounts = []
already_linked_accounts = []
invalid_accounts = []
selected_account_ids.each do |account_id|
account_data = accounts_data.find { |account| SophtronItem.external_account_id(account).to_s == account_id.to_s }
next unless account_data
if account_data[:account_name].blank?
invalid_accounts << account_id
Rails.logger.warn "SophtronItemsController - Skipping account #{account_id} with blank name"
next
end
sophtron_account = item.upsert_sophtron_account(account_data)
if sophtron_account.account_provider.present?
already_linked_accounts << account_data[:account_name]
next
end
ActiveRecord::Base.transaction do
account = Account.create_and_sync(
{
family: Current.family,
name: account_data[:account_name],
balance: 0,
currency: account_data[:currency] || "USD",
accountable_type: accountable_type,
accountable_attributes: {}
},
skip_initial_sync: true
)
AccountProvider.create!(account: account, provider: sophtron_account)
created_accounts << account
end
end
item.start_initial_load_later if created_accounts.any?
redirect_after_account_link(return_to, created_accounts, already_linked_accounts, invalid_accounts)
rescue Provider::Sophtron::Error => e
redirect_to new_account_path, alert: t(".api_error", message: e.message)
end
def select_existing_account
unless params[:account_id].present?
redirect_to accounts_path, alert: t(".no_account_specified")
return
end
@account = Current.family.accounts.find(params[:account_id])
if @account.account_providers.exists?
redirect_to accounts_path, alert: t(".account_already_linked")
return
end
item = configured_sophtron_item
unless item
render_or_redirect_setup_required
return
end
item.ensure_customer!
unless item.connected_to_institution?
prepare_connection_form(item, account: @account)
render :connect, layout: false
return
end
@available_accounts = item.reject_already_linked(item.fetch_remote_accounts)
@return_to = safe_return_to_path
if @available_accounts.empty?
redirect_to accounts_path, alert: t(".all_accounts_already_linked")
return
end
render layout: false
rescue Provider::Sophtron::Error => e
Rails.logger.error("Sophtron API error in select_existing_account: #{e.message}")
render_api_error(t(".api_error", message: e.message), accounts_path)
rescue StandardError => e
Rails.logger.error("Unexpected error in select_existing_account: #{e.class}: #{e.message}")
render_api_error(t(".unexpected_error"), accounts_path)
end
def link_existing_account
account_id = params[:account_id]
sophtron_account_id = params[:sophtron_account_id]
return_to = safe_return_to_path
unless account_id.present? && sophtron_account_id.present?
redirect_to accounts_path, alert: t(".missing_parameters")
return
end
account = Current.family.accounts.find(account_id)
if account.account_providers.exists?
redirect_to accounts_path, alert: t(".account_already_linked")
return
end
item = configured_sophtron_item
unless item&.connected_to_institution?
redirect_to accounts_path, alert: t(".no_institution_connected")
return
end
account_data = item.fetch_remote_accounts(force: true).find { |remote_account| SophtronItem.external_account_id(remote_account).to_s == sophtron_account_id.to_s }
unless account_data
redirect_to accounts_path, alert: t(".sophtron_account_not_found")
return
end
if account_data[:account_name].blank?
redirect_to accounts_path, alert: t(".invalid_account_name")
return
end
sophtron_account = item.upsert_sophtron_account(account_data)
if sophtron_account.account_provider.present?
redirect_to accounts_path, alert: t(".sophtron_account_already_linked")
return
end
AccountProvider.create!(account: account, provider: sophtron_account)
item.start_initial_load_later
redirect_to return_to || accounts_path, notice: t(".success", account_name: account.name)
rescue Provider::Sophtron::Error => e
Rails.logger.error("Sophtron API error in link_existing_account: #{e.message}")
redirect_to accounts_path, alert: t(".api_error", message: e.message)
end
def new
@sophtron_item = Current.family.sophtron_items.build
end
def create
@sophtron_item = Current.family.sophtron_items.build(sophtron_params)
@sophtron_item.name ||= t("sophtron_items.defaults.name")
if @sophtron_item.save
unless verify_and_provision_customer(@sophtron_item)
render_sophtron_panel_error(:new, @sophtron_item.last_connection_error)
return
end
render_sophtron_panel_success(:create)
else
render_sophtron_panel_error(:new, @sophtron_item.errors.full_messages.join(", "))
end
end
def edit
end
def update
if @sophtron_item.update(sophtron_params)
unless verify_and_provision_customer(@sophtron_item)
render_sophtron_panel_error(:edit, @sophtron_item.last_connection_error)
return
end
render_sophtron_panel_success(:update)
else
render_sophtron_panel_error(:edit, @sophtron_item.errors.full_messages.join(", "))
end
end
def destroy
begin
@sophtron_item.unlink_all!(dry_run: false)
rescue => e
Rails.logger.warn("Sophtron unlink during destroy failed: #{e.class} - #{e.message}")
end
@sophtron_item.destroy_later
redirect_to accounts_path, notice: t(".success")
end
def sync
@sophtron_item.sync_later unless @sophtron_item.syncing?
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
def setup_accounts
@api_error = fetch_sophtron_accounts_from_api
@sophtron_accounts = @sophtron_item.sophtron_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
supported_types = Provider::SophtronAdapter.supported_account_types
account_type_keys = {
"depository" => "Depository",
"credit_card" => "CreditCard",
"investment" => "Investment",
"loan" => "Loan",
"other_asset" => "OtherAsset"
}
all_account_type_options = account_type_keys.filter_map do |key, type|
next unless supported_types.include?(type)
[ t(".account_types.#{key}"), type ]
end
@account_type_options = [ [ t(".account_types.skip"), "skip" ] ] + all_account_type_options
@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 ] }
},
"Crypto" => {
label: nil,
options: [],
message: "Crypto accounts track cryptocurrency holdings."
},
"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] || {}
valid_types = Provider::SophtronAdapter.supported_account_types
created_accounts = []
skipped_count = 0
begin
ActiveRecord::Base.transaction do
account_types.each do |sophtron_account_id, selected_type|
if selected_type == "skip" || selected_type.blank?
skipped_count += 1
next
end
unless valid_types.include?(selected_type)
Rails.logger.warn("Invalid account type '#{selected_type}' submitted for Sophtron account #{sophtron_account_id}")
next
end
sophtron_account = @sophtron_item.sophtron_accounts.find_by(id: sophtron_account_id)
unless sophtron_account
Rails.logger.warn("Sophtron account #{sophtron_account_id} not found for item #{@sophtron_item.id}")
next
end
if sophtron_account.account_provider.present?
Rails.logger.info("Sophtron account #{sophtron_account_id} already linked, skipping")
next
end
selected_subtype = account_subtypes[sophtron_account_id]
selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank?
account = Account.create_and_sync(
{
family: Current.family,
name: sophtron_account.name,
balance: sophtron_account.balance || 0,
currency: sophtron_account.currency || "USD",
accountable_type: selected_type,
accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {}
},
skip_initial_sync: true
)
AccountProvider.create!(account: account, provider: sophtron_account)
created_accounts << account
end
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
Rails.logger.error("Sophtron account setup failed: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.first(10).join("\n"))
flash[:alert] = t(".creation_failed")
redirect_to accounts_path, status: :see_other
return
rescue StandardError => e
Rails.logger.error("Sophtron account setup failed unexpectedly: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.first(10).join("\n"))
flash[:alert] = t(".unexpected_error")
redirect_to accounts_path, status: :see_other
return
end
@sophtron_item.start_initial_load_later if created_accounts.any?
flash[:notice] = if created_accounts.any?
t(".success", count: created_accounts.count)
elsif skipped_count > 0
t(".all_skipped")
else
t(".no_accounts")
end
if turbo_frame_request?
@manual_accounts = Account.uncached {
Current.family.accounts.visible_manual.order(:name).to_a
}
@sophtron_items = Current.family.sophtron_items.ordered
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(@sophtron_item),
partial: "sophtron_items/sophtron_item",
locals: { sophtron_item: @sophtron_item }
)
] + Array(flash_notification_stream_items)
else
redirect_to accounts_path, status: :see_other
end
end
private
def configured_sophtron_item
Current.family.configured_sophtron_item
end
def normalized_security_answers
raw_answers = Array(params[:security_answers]).flatten
return if raw_answers.size > MAX_SECURITY_ANSWERS
return if raw_answers.any? { |answer| answer.to_s.length > MAX_SECURITY_ANSWER_LENGTH }
answers = raw_answers.filter_map do |answer|
answer.to_s.strip.presence
end
return if answers.empty?
answers
end
def sophtron_response_data!(response)
Provider::Sophtron.response_data!(response)
end
def verify_and_provision_customer(item)
provider = item.sophtron_provider
raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider
sophtron_response_data!(provider.health_check_auth)
item.ensure_customer!(provider: provider)
true
rescue Provider::Sophtron::Error => e
item.update(status: :requires_update, last_connection_error: e.message)
Rails.logger.error("Sophtron customer provisioning failed: #{e.message}")
false
end
def render_sophtron_panel_success(action_name)
if turbo_frame_request?
flash.now[:notice] = t("sophtron_items.#{action_name}.success")
@sophtron_items = Current.family.sophtron_items.ordered
render turbo_stream: [
turbo_stream.replace(
"sophtron-providers-panel",
partial: "settings/providers/sophtron_panel",
locals: { sophtron_items: @sophtron_items }
),
*flash_notification_stream_items
]
else
redirect_to accounts_path, notice: t("sophtron_items.#{action_name}.success"), status: :see_other
end
end
def render_sophtron_panel_error(view_name, message)
@error_message = message
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"sophtron-providers-panel",
partial: "settings/providers/sophtron_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
render view_name, status: :unprocessable_entity
end
end
def render_or_redirect_setup_required
if turbo_frame_request?
render partial: "sophtron_items/setup_required", layout: false
else
redirect_to settings_providers_path, alert: t("sophtron_items.select_accounts.no_credentials_configured")
end
end
def item_for_institution_connection(item)
return item unless connect_new_institution_flow? && should_create_sophtron_item_for_new_institution?(item)
Current.family.sophtron_items.create!(
name: item.name.presence || t("sophtron_items.defaults.name"),
user_id: item.user_id,
access_key: item.access_key,
base_url: item.base_url,
customer_id: item.customer_id,
customer_name: item.customer_name,
raw_customer_payload: item.raw_customer_payload,
sync_start_date: item.sync_start_date
)
end
def should_create_sophtron_item_for_new_institution?(item)
item.user_institution_id.present? ||
item.current_job_id.present? ||
item.institution_id.present? ||
item.institution_name.present? ||
item.sophtron_accounts.exists?
end
def prepare_connection_form(item, account: nil)
@sophtron_item = item
@account = account
@accountable_type = params[:accountable_type] || "Depository"
@return_to = safe_return_to_path
@connect_new_institution = connect_new_institution_flow?
@institution_search = params[:institution_name].to_s.strip
@institutions = []
if @institution_search.length >= 2
@institutions = sophtron_response_data!(item.sophtron_provider.search_institutions(@institution_search))
end
end
def render_account_selection(item, force_refresh: false)
@available_accounts = item.reject_already_linked(item.fetch_remote_accounts(force: force_refresh))
@accountable_type = params[:accountable_type] || "Depository"
@return_to = safe_return_to_path
if params[:account_id].present?
@account = Current.family.accounts.find(params[:account_id])
render :select_existing_account, layout: false
else
render :select_accounts, layout: false
end
end
def render_account_selection_if_accounts_available(item)
accounts = item.fetch_remote_accounts(force: true)
return false if accounts.empty?
item.update!(
current_job_id: nil,
last_connection_error: nil,
pending_account_setup: true,
status: :good
)
@available_accounts = item.reject_already_linked(accounts)
@accountable_type = params[:accountable_type] || "Depository"
@return_to = safe_return_to_path
if params[:account_id].present?
@account = Current.family.accounts.find(params[:account_id])
render :select_existing_account, layout: false
else
render :select_accounts, layout: false
end
true
rescue Provider::Sophtron::Error => e
Rails.logger.info("Sophtron accounts are not available after completed job #{item.current_job_id}: #{e.message}")
false
end
def render_pending_connection_status
if @poll_attempt >= connection_status_max_polls
render_connection_timeout
return
end
prepare_connection_status_context
@next_poll_attempt = @poll_attempt + 1
render :connection_status, layout: false
end
def prepare_connection_status_context
@accountable_type = params[:accountable_type] || "Depository"
@account_id = params[:account_id]
@return_to = safe_return_to_path
@poll_interval_ms = CONNECTION_STATUS_POLL_INTERVAL_MS
@post_mfa_polling = post_mfa_polling?
@max_poll_attempts = connection_status_max_polls
end
def requested_poll_attempt
poll_attempt = params[:poll_attempt].to_i
poll_attempt.positive? ? poll_attempt : 1
end
def render_connection_timeout
@poll_attempt = connection_status_max_polls if @poll_attempt.to_i > connection_status_max_polls
@poll_attempt = 1 if @poll_attempt.to_i < 1
@sophtron_item.update!(
last_connection_error: t(".timeout"),
status: :requires_update
)
prepare_connection_status_context
@timed_out = true
render :connection_status, layout: false
end
def connection_status_max_polls
if post_mfa_polling?
POST_MFA_CONNECTION_STATUS_MAX_POLLS
elsif login_progress_polling?
LOGIN_PROGRESS_CONNECTION_STATUS_MAX_POLLS
else
CONNECTION_STATUS_MAX_POLLS
end
end
def post_mfa_polling?
ActiveModel::Type::Boolean.new.cast(params[:post_mfa]) || post_mfa_job_payload?(@sophtron_item.raw_job_payload)
end
def post_mfa_job_payload?(job_payload)
job = (job_payload || {}).with_indifferent_access
job[:TokenInput].present? || %w[TokenInput TransactionTable].include?(job[:LastStep].to_s)
end
def login_progress_polling?
login_progress_job_payload?(@sophtron_item.raw_job_payload)
end
def login_progress_job_payload?(job_payload)
job = (job_payload || {}).with_indifferent_access
last_status = job[:LastStatus] || job[:last_status]
return false if Provider::Sophtron.failure_job_status?(last_status)
job[:LastStep].present? || job[:last_step].present? || last_status.present?
end
def prefetch_request?
[
request.headers["X-Sec-Purpose"],
request.headers["Sec-Purpose"],
request.headers["Purpose"]
].any? { |value| value.to_s.include?("prefetch") }
end
def render_institution_connection_error(message)
render_api_error(
message,
select_accounts_sophtron_items_path(connection_context_params.except(:post_mfa, "post_mfa")),
heading: t("sophtron_items.api_error.institution_unable_to_connect"),
issue_keys: %w[bank_credentials verification_code institution_timeout unsupported_mfa],
action_label: t("sophtron_items.api_error.try_again")
)
end
def sophtron_connection_failure_message(job)
last_status = job.with_indifferent_access[:LastStatus].to_s
return t("sophtron_items.connection_status.failed_timeout") if last_status.match?(/timeout/i)
t("sophtron_items.connection_status.failed")
end
def render_api_error(message, return_path, heading: nil, issue_keys: nil, action_label: nil)
render partial: "sophtron_items/api_error",
locals: {
error_message: message,
return_path: return_path,
heading: heading,
issue_keys: issue_keys,
action_label: action_label
},
layout: false
end
def redirect_after_account_link(return_to, created_accounts, already_linked_accounts, invalid_accounts)
if invalid_accounts.any? && created_accounts.empty? && already_linked_accounts.empty?
redirect_to new_account_path, alert: t(".invalid_account_names", count: invalid_accounts.count)
elsif invalid_accounts.any? && (created_accounts.any? || already_linked_accounts.any?)
redirect_to return_to || accounts_path,
alert: t(".partial_invalid",
created_count: created_accounts.count,
already_linked_count: already_linked_accounts.count,
invalid_count: invalid_accounts.count)
elsif created_accounts.any? && already_linked_accounts.any?
redirect_to return_to || accounts_path,
notice: t(".partial_success",
created_count: created_accounts.count,
already_linked_count: already_linked_accounts.count,
already_linked_names: already_linked_accounts.join(", "))
elsif created_accounts.any?
redirect_to return_to || accounts_path, notice: t(".success", count: created_accounts.count)
elsif already_linked_accounts.any?
redirect_to return_to || accounts_path,
alert: t(".all_already_linked",
count: already_linked_accounts.count,
names: already_linked_accounts.join(", "))
else
redirect_to new_account_path, alert: t(".link_failed")
end
end
def fetch_sophtron_accounts_from_api
return nil unless @sophtron_item.sophtron_accounts.empty?
return t("sophtron_items.setup_accounts.no_access_key") unless @sophtron_item.credentials_configured?
return t("sophtron_items.setup_accounts.no_institution_connected") unless @sophtron_item.connected_to_institution?
@sophtron_item.fetch_remote_accounts(force: true)
nil
rescue Provider::Sophtron::Error => e
Rails.logger.error("Sophtron API error: #{e.message}")
t("sophtron_items.setup_accounts.api_error")
rescue StandardError => e
Rails.logger.error("Unexpected error fetching Sophtron accounts: #{e.class}: #{e.message}")
Rails.logger.error(e.backtrace.first(10).join("\n"))
t("sophtron_items.setup_accounts.api_error")
end
def set_sophtron_item
@sophtron_item = Current.family.sophtron_items.find(params[:id])
end
def sophtron_params
params.require(:sophtron_item).permit(:name, :user_id, :access_key, :base_url, :sync_start_date)
end
def connection_context_params
params.permit(:accountable_type, :account_id, :return_to, :post_mfa, :connect_new_institution).to_h.compact
end
def connect_new_institution_flow?
ActiveModel::Type::Boolean.new.cast(params[:connect_new_institution])
end
def safe_return_to_path
return nil if params[:return_to].blank?
return_to = params[:return_to].to_s
begin
uri = URI.parse(return_to)
return nil if uri.scheme.present? || uri.host.present?
return nil if return_to.start_with?("//")
return nil unless return_to.start_with?("/")
return_to
rescue URI::InvalidURIError
nil
end
end
end