mirror of
https://github.com/we-promise/sure.git
synced 2026-05-22 03:55:01 +00:00
* Add manual Sophtron sync flow (#1705) Branch-to-branch merge. * Copy edits * Make Sophtron manual sync institution scoped * Populate Sophtron manual sync stats * Restore Sophtron bank credential copy * Address Sophtron manual sync review feedback * Scope manual sync processing failure handling * Hide raw Sophtron processor errors from flash * Clear Sophtron manual sync pointers on provider errors * Keep manual Sophtron MFA on manual sync records * Preserve manual sync processing error details
1251 lines
44 KiB
Ruby
1251 lines
44 KiB
Ruby
class SophtronItemsController < ApplicationController
|
|
include SyncStats::Collector
|
|
|
|
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
|
|
MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY = "manual_sync_processed_sophtron_account_ids"
|
|
|
|
before_action :set_sophtron_item, only: [
|
|
:show, :edit, :update, :destroy, :connect_institution, :sync,
|
|
:connection_status, :submit_mfa, :toggle_manual_sync,
|
|
: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, :toggle_manual_sync,
|
|
: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)
|
|
if manual_sync_flow?
|
|
complete_manual_sync_from_job(job)
|
|
return
|
|
end
|
|
|
|
@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 Provider::Sophtron.job_completed?(job)
|
|
if manual_sync_flow?
|
|
complete_manual_sync_from_job(job)
|
|
return
|
|
end
|
|
|
|
if post_mfa_polling?
|
|
return if render_account_selection_if_accounts_available(@sophtron_item)
|
|
end
|
|
|
|
render_pending_connection_status
|
|
elsif Provider::Sophtron.job_failed?(job)
|
|
failure_message = sophtron_connection_failure_message(job)
|
|
@sophtron_item.update!(
|
|
current_job_id: nil,
|
|
current_job_sophtron_account_id: nil,
|
|
user_institution_id: (manual_sync_flow? ? @sophtron_item.user_institution_id : nil),
|
|
last_connection_error: failure_message,
|
|
status: :requires_update
|
|
)
|
|
fail_manual_sync!(manual_sync_record, failure_message) if manual_sync_flow?
|
|
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
|
|
if @sophtron_item.manual_sync_required?
|
|
start_manual_sync
|
|
return
|
|
end
|
|
|
|
@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 toggle_manual_sync
|
|
toggle_accounts = manual_sync_toggle_sophtron_accounts
|
|
|
|
if toggle_accounts.exists?
|
|
enabled = if @sophtron_item.manual_sync?
|
|
@sophtron_item.sophtron_accounts.where.not(id: toggle_accounts.select(:id)).update_all(manual_sync: true, updated_at: Time.current)
|
|
false
|
|
else
|
|
!toggle_accounts.requires_manual_sync.exists?
|
|
end
|
|
toggle_accounts.update_all(manual_sync: enabled, updated_at: Time.current)
|
|
@sophtron_item.update!(manual_sync: false) unless enabled
|
|
elsif params[:institution_key].present? || params[:user_institution_id].present?
|
|
redirect_back_or_to accounts_path, alert: t("sophtron_items.sync.no_linked_accounts")
|
|
return
|
|
else
|
|
@sophtron_item.update!(manual_sync: !@sophtron_item.manual_sync?)
|
|
enabled = @sophtron_item.manual_sync?
|
|
end
|
|
|
|
respond_to do |format|
|
|
format.html { redirect_back_or_to accounts_path, notice: t(".success_#{enabled ? 'enabled' : 'disabled'}") }
|
|
format.turbo_stream do
|
|
flash.now[:notice] = t(".success_#{enabled ? 'enabled' : 'disabled'}")
|
|
render turbo_stream: [
|
|
turbo_stream.replace(
|
|
ActionView::RecordIdentifier.dom_id(@sophtron_item),
|
|
partial: "sophtron_items/sophtron_item",
|
|
locals: { sophtron_item: @sophtron_item.reload }
|
|
),
|
|
*flash_notification_stream_items
|
|
]
|
|
end
|
|
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 start_manual_sync
|
|
if @sophtron_item.current_job_sophtron_account_id.present?
|
|
redirect_to active_manual_sync_path, alert: t(".already_running")
|
|
return
|
|
end
|
|
|
|
unless linked_manual_sync_sophtron_accounts.exists?
|
|
redirect_back_or_to accounts_path, alert: t(".no_linked_accounts")
|
|
return
|
|
end
|
|
|
|
sync = @sophtron_item.syncs.create!
|
|
sync.start! if sync.may_start?
|
|
@manual_sync = sync
|
|
|
|
provider = @sophtron_item.sophtron_provider
|
|
raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider
|
|
|
|
reset_manual_sync_progress!(sync)
|
|
start_next_manual_sync_account(sync, provider)
|
|
rescue Provider::Sophtron::Error => e
|
|
clear_or_fail_manual_sync_after_error!(sync, e.message) if defined?(sync) && sync.present?
|
|
Rails.logger.error("Sophtron manual sync error: #{e.message}")
|
|
redirect_back_or_to accounts_path, alert: t(".api_error", message: e.message)
|
|
end
|
|
|
|
def active_manual_sync_path
|
|
return accounts_path if @sophtron_item.current_job_id.blank?
|
|
|
|
connection_status_sophtron_item_path(
|
|
@sophtron_item,
|
|
connection_context_params.merge(
|
|
manual_sync: true,
|
|
sync_id: manual_sync_record&.id,
|
|
sophtron_account_id: @sophtron_item.current_job_sophtron_account_id
|
|
)
|
|
)
|
|
end
|
|
|
|
def start_next_manual_sync_account(sync, provider)
|
|
sophtron_account = next_manual_sync_sophtron_account(sync)
|
|
|
|
unless sophtron_account
|
|
@sophtron_item.update!(
|
|
current_job_id: nil,
|
|
current_job_sophtron_account_id: nil,
|
|
last_connection_error: nil,
|
|
status: :good
|
|
)
|
|
sync.finalize_if_all_children_finalized
|
|
flash.discard(:alert)
|
|
@manual_sync = sync
|
|
render :manual_sync_complete, layout: false
|
|
return
|
|
end
|
|
|
|
start_manual_sync_for_account(sophtron_account, provider, sync)
|
|
end
|
|
|
|
def start_manual_sync_for_account(sophtron_account, provider, sync)
|
|
refresh_response = sophtron_response_data!(provider.refresh_account(sophtron_account.account_id)).with_indifferent_access
|
|
job_id = refresh_response[:JobID] || refresh_response[:job_id]
|
|
|
|
if job_id.blank?
|
|
complete_manual_sync!(sophtron_account, provider, sync)
|
|
start_next_manual_sync_account(sync, provider)
|
|
return
|
|
end
|
|
|
|
@sophtron_item.update!(
|
|
current_job_id: job_id,
|
|
current_job_sophtron_account_id: sophtron_account.id,
|
|
raw_job_payload: refresh_response,
|
|
job_status: nil,
|
|
last_connection_error: nil,
|
|
status: :good
|
|
)
|
|
|
|
job = sophtron_response_data!(provider.get_job_information(job_id))
|
|
@sophtron_item.upsert_job_snapshot!(job)
|
|
|
|
if Provider::Sophtron.job_requires_input?(job)
|
|
@challenge = @sophtron_item.build_mfa_challenge(job)
|
|
prepare_connection_status_context
|
|
render :mfa, layout: false
|
|
elsif Provider::Sophtron.job_failed?(job)
|
|
failure_message = t(".failed")
|
|
fail_manual_sync_and_clear_job!(sync, failure_message)
|
|
redirect_back_or_to accounts_path, alert: failure_message
|
|
elsif Provider::Sophtron.job_success?(job) || Provider::Sophtron.job_completed?(job)
|
|
complete_manual_sync!(sophtron_account, provider, sync)
|
|
start_next_manual_sync_account(sync, provider)
|
|
else
|
|
@poll_attempt = 1
|
|
render_pending_connection_status
|
|
end
|
|
end
|
|
|
|
def complete_manual_sync_from_job(job)
|
|
sophtron_account = @sophtron_item.current_job_sophtron_account
|
|
sophtron_account ||= linked_manual_sync_sophtron_accounts.find_by(id: params[:sophtron_account_id]) if params[:sophtron_account_id].present?
|
|
sync = manual_sync_record
|
|
|
|
unless sophtron_account && sync
|
|
@sophtron_item.update!(current_job_id: nil, current_job_sophtron_account_id: nil)
|
|
render_api_error(t("sophtron_items.sync.no_linked_accounts"), accounts_path)
|
|
return
|
|
end
|
|
|
|
provider = @sophtron_item.sophtron_provider
|
|
complete_manual_sync!(sophtron_account, provider, sync)
|
|
start_next_manual_sync_account(sync, provider)
|
|
rescue Provider::Sophtron::Error => e
|
|
fail_manual_sync_and_clear_job!(sync, e.message) if defined?(sync) && sync.present?
|
|
render_api_error(t("sophtron_items.sync.api_error", message: e.message), accounts_path)
|
|
end
|
|
|
|
def complete_manual_sync!(sophtron_account, provider, sync)
|
|
raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider
|
|
|
|
result = SophtronItem::Importer.new(@sophtron_item, sophtron_provider: provider, sync: sync)
|
|
.import_transactions_after_refresh(sophtron_account)
|
|
|
|
unless result[:success]
|
|
error_message = result[:error] || t("sophtron_items.sync.failed")
|
|
fail_manual_sync_and_clear_job!(sync, error_message)
|
|
raise Provider::Sophtron::Error.new(error_message, :api_error)
|
|
end
|
|
|
|
processing_result = process_manual_sync_account!(sync, sophtron_account)
|
|
mark_manual_sync_account_processed!(sync, sophtron_account)
|
|
collect_manual_sync_stats!(sync, processing_result)
|
|
@sophtron_item.update!(
|
|
current_job_id: nil,
|
|
current_job_sophtron_account_id: nil,
|
|
last_connection_error: nil,
|
|
status: :good
|
|
)
|
|
|
|
if (account = sophtron_account.current_account)
|
|
account.sync_later(
|
|
parent_sync: sync,
|
|
window_start_date: sync.window_start_date,
|
|
window_end_date: sync.window_end_date
|
|
)
|
|
else
|
|
sync.finalize_if_all_children_finalized
|
|
end
|
|
|
|
@manual_sync_account = sophtron_account
|
|
@manual_sync = sync
|
|
end
|
|
|
|
def process_manual_sync_account!(sync, sophtron_account)
|
|
SophtronAccount::Processor.new(sophtron_account.reload).process
|
|
rescue StandardError => e
|
|
Rails.logger.error("Sophtron manual sync processing error: #{e.class} - #{e.message}")
|
|
fail_manual_sync_and_clear_job!(sync, e.message)
|
|
raise Provider::Sophtron::Error.new(t("sophtron_items.sync.processing_failed"), :api_error)
|
|
end
|
|
|
|
def fail_manual_sync_and_clear_job!(sync, message)
|
|
clear_manual_sync_job!(message, status: :requires_update)
|
|
fail_manual_sync!(sync, message)
|
|
end
|
|
|
|
def clear_or_fail_manual_sync_after_error!(sync, message)
|
|
if sync.failed?
|
|
clear_manual_sync_job!(@sophtron_item.last_connection_error, status: :requires_update)
|
|
else
|
|
fail_manual_sync_and_clear_job!(sync, message)
|
|
end
|
|
end
|
|
|
|
def clear_manual_sync_job!(message = nil, status: nil)
|
|
attributes = {
|
|
current_job_id: nil,
|
|
current_job_sophtron_account_id: nil
|
|
}
|
|
attributes[:last_connection_error] = message if message.present?
|
|
attributes[:status] = status if status.present?
|
|
|
|
@sophtron_item.update!(attributes)
|
|
end
|
|
|
|
def fail_manual_sync!(sync, message)
|
|
return unless sync
|
|
|
|
sync.start! if sync.may_start?
|
|
sync.fail! if sync.may_fail?
|
|
sync.update!(error: message)
|
|
end
|
|
|
|
def manual_sync_record
|
|
return @manual_sync if defined?(@manual_sync) && @manual_sync.present?
|
|
|
|
sync = @sophtron_item.syncs.find_by(id: params[:sync_id]) if params[:sync_id].present?
|
|
sync || visible_manual_sync_record
|
|
end
|
|
|
|
def visible_manual_sync_record
|
|
@sophtron_item.syncs.visible.ordered.detect do |sync|
|
|
sync.sync_stats.to_h.key?(MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY)
|
|
end
|
|
end
|
|
|
|
def linked_manual_sync_sophtron_accounts
|
|
@sophtron_item.manual_sync_sophtron_accounts
|
|
end
|
|
|
|
def manual_sync_toggle_sophtron_accounts
|
|
accounts = @sophtron_item.sophtron_accounts.order(:created_at, :id)
|
|
institution_key = params[:institution_key].presence || params[:user_institution_id]
|
|
return accounts if institution_key.blank?
|
|
|
|
account_ids = accounts.select do |sophtron_account|
|
|
sophtron_account.institution_key.to_s == institution_key.to_s
|
|
end.map(&:id)
|
|
|
|
accounts.where(id: account_ids)
|
|
end
|
|
|
|
def next_manual_sync_sophtron_account(sync)
|
|
processed_ids = manual_sync_processed_sophtron_account_ids(sync)
|
|
linked_manual_sync_sophtron_accounts.detect { |sophtron_account| processed_ids.exclude?(sophtron_account.id.to_s) }
|
|
end
|
|
|
|
def reset_manual_sync_progress!(sync)
|
|
sync.update!(sync_stats: { MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => [] })
|
|
end
|
|
|
|
def mark_manual_sync_account_processed!(sync, sophtron_account)
|
|
processed_ids = manual_sync_processed_sophtron_account_ids(sync)
|
|
processed_ids << sophtron_account.id.to_s
|
|
stats = sync.sync_stats.to_h
|
|
sync.update!(sync_stats: stats.merge(MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => processed_ids.uniq))
|
|
end
|
|
|
|
def collect_manual_sync_stats!(sync, processing_result)
|
|
mark_import_started(sync)
|
|
collect_setup_stats(sync, provider_accounts: @sophtron_item.sophtron_accounts.includes(:account_provider, :account))
|
|
|
|
account_ids = @sophtron_item.sophtron_accounts
|
|
.where(id: manual_sync_processed_sophtron_account_ids(sync))
|
|
.includes(:account_provider)
|
|
.filter_map { |sophtron_account| sophtron_account.current_account&.id }
|
|
|
|
collect_transaction_stats(
|
|
sync,
|
|
account_ids: account_ids,
|
|
source: "sophtron",
|
|
window_start: sync.syncing_at || sync.created_at,
|
|
window_end: Time.current
|
|
)
|
|
|
|
collect_manual_sync_health_stats!(sync, processing_result)
|
|
end
|
|
|
|
def collect_manual_sync_health_stats!(sync, processing_result)
|
|
if processing_result.is_a?(Hash) && processing_result[:success] == false
|
|
errors = Array(processing_result[:errors]).presence || [ { message: t("sophtron_items.sync.failed"), category: "transaction_import" } ]
|
|
collect_health_stats(sync, errors: errors)
|
|
elsif sync.sync_stats.to_h["total_errors"].to_i.zero?
|
|
collect_health_stats(sync, errors: nil)
|
|
end
|
|
end
|
|
|
|
def manual_sync_processed_sophtron_account_ids(sync)
|
|
Array(sync.sync_stats.to_h[MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY]).map(&:to_s)
|
|
end
|
|
|
|
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
|
|
@manual_sync_flow = manual_sync_flow?
|
|
@manual_sync_id = manual_sync_record&.id if @manual_sync_flow
|
|
@manual_sync_sophtron_account_id = params[:sophtron_account_id] || @sophtron_item.current_job_sophtron_account_id
|
|
@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 manual_sync_flow?
|
|
ActiveModel::Type::Boolean.new.cast(params[:manual_sync]) || @sophtron_item.current_job_sophtron_account_id.present?
|
|
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[bad_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, :manual_sync, :sync_id, :sophtron_account_id, :institution_key, :user_institution_id).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
|