mirror of
https://github.com/we-promise/sure.git
synced 2026-05-09 21:54:58 +00:00
* 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>
915 lines
32 KiB
Ruby
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
|