Files
sure/app/controllers/sophtron_items_controller.rb
Juan José Mata c92b984cef [codex] Add Sophtron manual sync fixes (#1714)
* 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
2026-05-09 21:55:20 +02:00

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