diff --git a/app/controllers/sophtron_items_controller.rb b/app/controllers/sophtron_items_controller.rb index 73fc9c730..81a86cd60 100644 --- a/app/controllers/sophtron_items_controller.rb +++ b/app/controllers/sophtron_items_controller.rb @@ -1,5 +1,22 @@ class SophtronItemsController < ApplicationController - before_action :set_sophtron_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + 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 @@ -9,122 +26,190 @@ class SophtronItemsController < ApplicationController def show end - # Preload Sophtron accounts in background (async, non-blocking) def preload_accounts - begin - # Check if family has credentials - unless Current.family.has_sophtron_credentials? - render json: { success: false, error: "no_credentials_configured", has_accounts: false } - return - end - - cache_key = "sophtron_accounts_#{Current.family.id}" - - # Check if already cached - cached_accounts = Rails.cache.read(cache_key) - - if cached_accounts.present? - render json: { success: true, has_accounts: cached_accounts.any?, cached: true } - return - end - - # Fetch from API - sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family) - - unless sophtron_provider.present? - render json: { success: false, error: "no_access_key", has_accounts: false } - return - end - - response = sophtron_provider.get_accounts - available_accounts = response.data[:accounts] || [] - - # Cache the accounts for 5 minutes - Rails.cache.write(cache_key, available_accounts, expires_in: 5.minutes) - - render json: { success: true, has_accounts: available_accounts.any?, cached: false } - rescue Provider::Error => e - Rails.logger.error("Sophtron preload error: #{e.message}") - # API error (bad key, network issue, etc) - keep button visible, show error when clicked - 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}") - # Unexpected error - keep button visible, show error when clicked - render json: { success: false, error: "unexpected_error", error_message: t(".unexpected_error"), has_accounts: nil } + 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 - # Fetch available accounts from Sophtron API and show selection UI def select_accounts - begin - # Check if family has Sophtron credentials configured - unless Current.family.has_sophtron_credentials? - if turbo_frame_request? - # Render setup modal for turbo frame requests - render partial: "sophtron_items/setup_required", layout: false - else - # Redirect for regular requests - redirect_to settings_providers_path, - alert: t(".no_credentials_configured") - end - return - end - - cache_key = "sophtron_accounts_#{Current.family.id}" - - # Try to get cached accounts first - @available_accounts = Rails.cache.read(cache_key) - - # If not cached, fetch from API - if @available_accounts.nil? - sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family) - - unless sophtron_provider.present? - redirect_to settings_providers_path, alert: t(".no_access_key") - return - end - - response = sophtron_provider.get_accounts - @available_accounts = response.data[:accounts] || [] - - # Cache the accounts for 5 minutes - Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes) - end - - # Filter out already linked accounts - sophtron_item = Current.family.sophtron_items.first - if sophtron_item - linked_account_ids = sophtron_item.sophtron_accounts.joins(:account_provider).pluck(:account_id) - @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } - end - - @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::Error => e - Rails.logger.error("Sophtron API error in select_accounts: #{e.message}") - @error_message = t(".api_error") - @return_path = safe_return_to_path - render partial: "sophtron_items/api_error", - locals: { error_message: @error_message, return_path: @return_path }, - layout: false - rescue StandardError => e - Rails.logger.error("Unexpected error in select_accounts: #{e.class}: #{e.message}") - @error_message = t(".unexpected_error") - @return_path = safe_return_to_path - render partial: "sophtron_items/api_error", - locals: { error_message: @error_message, return_path: @return_path }, - layout: false + 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 - # Create accounts from selected Sophtron accounts def link_accounts selected_account_ids = params[:account_ids] || [] accountable_type = params[:accountable_type] || "Depository" @@ -135,194 +220,103 @@ class SophtronItemsController < ApplicationController return end - # Create or find sophtron_item for this family - sophtron_item = Current.family.sophtron_items.first_or_create!( - name: t("sophtron_items.defaults.name") - ) - - # Fetch account details from API - sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family) - unless sophtron_provider.present? - redirect_to new_account_path, alert: t(".no_access_key") + 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 - response = sophtron_provider.get_accounts + accounts_data = item.fetch_remote_accounts(force: true) created_accounts = [] already_linked_accounts = [] invalid_accounts = [] selected_account_ids.each do |account_id| - # Find the account data from API response - account_data = response.data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s } + account_data = accounts_data.find { |account| SophtronItem.external_account_id(account).to_s == account_id.to_s } next unless account_data - # Validate account name is not blank (required by Account model) if account_data[:account_name].blank? invalid_accounts << account_id Rails.logger.warn "SophtronItemsController - Skipping account #{account_id} with blank name" next end - # Create or find sophtron_account - sophtron_account = sophtron_item.sophtron_accounts.find_or_initialize_by( - account_id: account_id.to_s - ) - sophtron_account.upsert_sophtron_snapshot!(account_data) - sophtron_account.save! - # Check if this sophtron_account is already linked + sophtron_account = item.upsert_sophtron_account(account_data) + if sophtron_account.account_provider.present? already_linked_accounts << account_data[:account_name] next end - # Create the internal Account with proper balance initialization - account = Account.create_and_sync( - { - family: Current.family, - name: account_data[:account_name], - balance: 0, # Initial balance will be set during sync - currency: account_data[:currency] || "USD", - accountable_type: accountable_type, - accountable_attributes: {} - }, - skip_initial_sync: true - ) - # Link account to sophtron_account via account_providers join table - AccountProvider.create!( - account: account, - provider: sophtron_account - ) + 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 + ) - created_accounts << account + AccountProvider.create!(account: account, provider: sophtron_account) + created_accounts << account + end end - # Trigger sync to fetch transactions if any accounts were created - sophtron_item.sync_later if created_accounts.any? - - # Build appropriate flash message - if invalid_accounts.any? && created_accounts.empty? && already_linked_accounts.empty? - # All selected accounts were invalid (blank names) - 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?) - # Some accounts were created/already linked, but some had invalid names - 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 - rescue Provider::Error => e - redirect_to new_account_path, alert: t(".api_error") - Rails.logger.error("Sophtron API error in link_accounts: #{e.message}") + 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 - # Fetch available Sophtron accounts to link with an existing account def select_existing_account - account_id = params[:account_id] - - unless account_id.present? + unless params[:account_id].present? redirect_to accounts_path, alert: t(".no_account_specified") return end - @account = Current.family.accounts.find(account_id) + @account = Current.family.accounts.find(params[:account_id]) - # Check if account is already linked if @account.account_providers.exists? redirect_to accounts_path, alert: t(".account_already_linked") return end - # Check if family has Sophtron credentials configured - unless Current.family.has_sophtron_credentials? - if turbo_frame_request? - # Render setup modal for turbo frame requests - render partial: "sophtron_items/setup_required", layout: false - else - # Redirect for regular requests - redirect_to settings_providers_path, - alert: t(".no_credentials_configured", - default: "Please configure your Sophtron API key first in Provider Settings.") - end + item = configured_sophtron_item + unless item + render_or_redirect_setup_required return end - begin - cache_key = "sophtron_accounts_#{Current.family.id}" + item.ensure_customer! - # Try to get cached accounts first - @available_accounts = Rails.cache.read(cache_key) - - # If not cached, fetch from API - if @available_accounts.nil? - sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family) - - unless sophtron_provider.present? - redirect_to settings_providers_path, alert: t(".no_access_key") - return - end - - response = sophtron_provider.get_accounts - @available_accounts = response.data[:accounts] || [] - - # Cache the accounts for 5 minutes - Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes) - end - - if @available_accounts.empty? - redirect_to accounts_path, alert: t(".no_accounts_found") - return - end - - # Filter out already linked accounts - sophtron_item = Current.family.sophtron_items.first - if sophtron_item - linked_account_ids = sophtron_item.sophtron_accounts.joins(:account_provider).pluck(:account_id) - @available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) } - end - - if @available_accounts.empty? - redirect_to accounts_path, alert: t(".all_accounts_already_linked") - return - end - - @return_to = safe_return_to_path - - render layout: false - rescue Provider::Error => e - Rails.logger.error("Sophtron API error in select_existing_account: #{e.message}") - @error_message = t(".api_error", message: e.message) - render partial: "sophtron_items/api_error", - locals: { error_message: @error_message, return_path: accounts_path }, - layout: false - rescue StandardError => e - Rails.logger.error("Unexpected error in select_existing_account: #{e.class}: #{e.message}") - @error_message = t(".unexpected_error") - render partial: "sophtron_items/api_error", - locals: { error_message: @error_message, return_path: accounts_path }, - layout: false + 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 - # Link a selected Sophtron account to an existing account def link_existing_account account_id = params[:account_id] sophtron_account_id = params[:sophtron_account_id] @@ -333,67 +327,44 @@ class SophtronItemsController < ApplicationController return end - @account = Current.family.accounts.find(account_id) + account = Current.family.accounts.find(account_id) - # Check if account is already linked - if @account.account_providers.exists? + if account.account_providers.exists? redirect_to accounts_path, alert: t(".account_already_linked") return end - # Create or find sophtron_item for this family - sophtron_item = Current.family.sophtron_items.first_or_create!( - name: "Sophtron Connection" - ) - - # Fetch account details from API - sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family) - unless sophtron_provider.present? - redirect_to accounts_path, alert: t(".no_access_key") + item = configured_sophtron_item + unless item&.connected_to_institution? + redirect_to accounts_path, alert: t(".no_institution_connected") return end - response = sophtron_provider.get_accounts - - # Find the selected Sophtron account data - account_data = response.data[:accounts].find { |acc| acc[:id].to_s == sophtron_account_id.to_s } + 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 - # Validate account name is not blank (required by Account model) if account_data[:account_name].blank? redirect_to accounts_path, alert: t(".invalid_account_name") return end - # Create or find sophtron_account - sophtron_account = sophtron_item.sophtron_accounts.find_or_initialize_by( - account_id: sophtron_account_id.to_s - ) - sophtron_account.upsert_sophtron_snapshot!(account_data) - sophtron_account.save! + sophtron_account = item.upsert_sophtron_account(account_data) - # Check if this sophtron_account is already linked to another account if sophtron_account.account_provider.present? redirect_to accounts_path, alert: t(".sophtron_account_already_linked") return end - # Link account to sophtron_account via account_providers join table - AccountProvider.create!( - account: @account, - provider: sophtron_account - ) + AccountProvider.create!(account: account, provider: sophtron_account) + item.start_initial_load_later - # Trigger sync to fetch transactions - sophtron_item.sync_later - redirect_to return_to || accounts_path, - notice: t(".success", account_name: @account.name) - rescue Provider::Error => e + 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") + redirect_to accounts_path, alert: t(".api_error", message: e.message) end def new @@ -403,84 +374,48 @@ class SophtronItemsController < ApplicationController def create @sophtron_item = Current.family.sophtron_items.build(sophtron_params) @sophtron_item.name ||= t("sophtron_items.defaults.name") + if @sophtron_item.save - # Trigger initial sync to fetch accounts - @sophtron_item.sync_later - if turbo_frame_request? - flash.now[:notice] = t(".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(".success"), status: :see_other + 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 - @error_message = @sophtron_item.errors.full_messages.join(", ") - 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 :new, status: :unprocessable_entity - end + render_sophtron_panel_error(:new, @sophtron_item.errors.full_messages.join(", ")) end end + def edit end def update if @sophtron_item.update(sophtron_params) - if turbo_frame_request? - flash.now[:notice] = t(".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(".success"), status: :see_other + 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 - @error_message = @sophtron_item.errors.full_messages.join(", ") - 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 :edit, status: :unprocessable_entity - end + render_sophtron_panel_error(:edit, @sophtron_item.errors.full_messages.join(", ")) end end def destroy - # Ensure we detach provider links before scheduling deletion 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 - unless @sophtron_item.syncing? - @sophtron_item.sync_later - end + @sophtron_item.sync_later unless @sophtron_item.syncing? respond_to do |format| format.html { redirect_back_or_to accounts_path } @@ -488,20 +423,14 @@ class SophtronItemsController < ApplicationController end end - # Show unlinked Sophtron accounts for setup (similar to SimpleFIN setup_accounts) def setup_accounts - # First, ensure we have the latest accounts from the API @api_error = fetch_sophtron_accounts_from_api - # Get Sophtron accounts that are not linked (no AccountProvider) @sophtron_accounts = @sophtron_item.sophtron_accounts .left_joins(:account_provider) .where(account_providers: { id: nil }) - # Get supported account types from the adapter supported_types = Provider::SophtronAdapter.supported_account_types - - # Map of account type keys to their internal values account_type_keys = { "depository" => "Depository", "credit_card" => "CreditCard", @@ -510,16 +439,12 @@ class SophtronItemsController < ApplicationController "other_asset" => "OtherAsset" } - # Build account type options using i18n, filtering to supported types all_account_type_options = account_type_keys.filter_map do |key, type| next unless supported_types.include?(type) [ t(".account_types.#{key}"), type ] end - # Add "Skip" option at the beginning @account_type_options = [ [ t(".account_types.skip"), "skip" ] ] + all_account_type_options - - # Subtype options for each account type @subtype_options = { "Depository" => { label: "Account Subtype:", @@ -554,65 +479,50 @@ class SophtronItemsController < ApplicationController def complete_account_setup account_types = params[:account_types] || {} account_subtypes = params[:account_subtypes] || {} - - # Valid account types for this provider 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| - # Skip accounts marked as "skip" if selected_type == "skip" || selected_type.blank? skipped_count += 1 next end - # Validate account type is supported unless valid_types.include?(selected_type) Rails.logger.warn("Invalid account type '#{selected_type}' submitted for Sophtron account #{sophtron_account_id}") next end - # Find account - scoped to this item to prevent cross-item manipulation 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 - # Skip if already linked (race condition protection) 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] - - # Default subtype for CreditCard since it only has one option selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank? - # Create account with user-selected type and subtype (raises on failure) 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 } : {} + 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 ) - # Link account to sophtron_account via account_providers join table (raises on failure) - AccountProvider.create!( - account: account, - provider: sophtron_account - ) - + AccountProvider.create!(account: account, provider: sophtron_account) created_accounts << account end end @@ -630,25 +540,19 @@ class SophtronItemsController < ApplicationController return end - # Trigger a sync to process transactions - @sophtron_item.sync_later if created_accounts.any? + @sophtron_item.start_initial_load_later if created_accounts.any? - # Set appropriate flash message - if created_accounts.any? - flash[:notice] = t(".success", count: created_accounts.count) + flash[:notice] = if created_accounts.any? + t(".success", count: created_accounts.count) elsif skipped_count > 0 - flash[:notice] = t(".all_skipped") + t(".all_skipped") else - flash[:notice] = t(".no_accounts") + t(".no_accounts") end if turbo_frame_request? - # Recompute data needed by Accounts#index partials @manual_accounts = Account.uncached { - Current.family.accounts - .visible_manual - .order(:name) - .to_a + Current.family.accounts.visible_manual.order(:name).to_a } @sophtron_items = Current.family.sophtron_items.ordered manual_accounts_stream = if @manual_accounts.any? @@ -676,50 +580,303 @@ class SophtronItemsController < ApplicationController private - # Fetch Sophtron accounts from the API and store them locally - # Returns nil on success, or an error message string on failure + 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 - # Skip if we already have accounts cached 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? - # Validate Access key is configured - unless @sophtron_item.credentials_configured? - return t("sophtron_items.setup_accounts.no_access_key") - end - - # Use the specific sophtron_item's provider (scoped to this family's item) - sophtron_provider = @sophtron_item.sophtron_provider - unless sophtron_provider.present? - return t("sophtron_items.setup_accounts.no_access_key") - end - - begin - response = sophtron_provider.get_accounts - available_accounts = response.data[:accounts] || [] - - if available_accounts.empty? - return nil - end - - available_accounts.each_with_index do |account_data, index| - next if account_data[:account_name].blank? - - sophtron_account = @sophtron_item.sophtron_accounts.find_or_initialize_by( - account_id: account_data[:account_id].to_s - ) - sophtron_account.upsert_sophtron_snapshot!(account_data) - sophtron_account.save! - end - - nil # Success - rescue Provider::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 + @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 @@ -730,27 +887,27 @@ class SophtronItemsController < ApplicationController params.require(:sophtron_item).permit(:name, :user_id, :access_key, :base_url, :sync_start_date) end - # Sanitize return_to parameter to prevent XSS attacks - # Only allow internal paths, reject external URLs and javascript: URIs + 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 - # Parse the URL to check if it's external begin uri = URI.parse(return_to) - - # Reject absolute URLs with schemes (http:, https:, javascript:, etc.) - # Only allow relative paths return nil if uri.scheme.present? || uri.host.present? return nil if return_to.start_with?("//") - # Ensure the path starts with / (is a relative path) return nil unless return_to.start_with?("/") return_to rescue URI::InvalidURIError - # If the URI is invalid, reject it nil end end diff --git a/app/javascript/controllers/polling_controller.js b/app/javascript/controllers/polling_controller.js index 0e9e743f8..8478b2f69 100644 --- a/app/javascript/controllers/polling_controller.js +++ b/app/javascript/controllers/polling_controller.js @@ -6,6 +6,7 @@ export default class extends Controller { static values = { url: String, interval: { type: Number, default: 3000 }, + frameId: String, }; connect() { @@ -33,10 +34,16 @@ export default class extends Controller { async refresh() { try { + const frame = this.frameElement(); + if (!frame) { + this.stopPolling(); + return; + } + const response = await fetch(this.urlValue, { headers: { Accept: "text/html", - "Turbo-Frame": this.element.id, + "Turbo-Frame": frame.id, }, }); @@ -46,13 +53,19 @@ export default class extends Controller { template.innerHTML = html; const newFrame = template.content.querySelector( - `turbo-frame#${this.element.id}`, + `turbo-frame#${this.cssEscape(frame.id)}`, ); if (newFrame) { - this.element.innerHTML = newFrame.innerHTML; + if (frame === this.element) { + this.syncPollingAttributes(newFrame); + } + frame.innerHTML = newFrame.innerHTML; // Check if we should stop polling (no more pending/processing exports) - if (!newFrame.hasAttribute("data-polling-url-value")) { + if ( + frame === this.element && + !newFrame.hasAttribute("data-polling-url-value") + ) { this.stopPolling(); } } @@ -61,4 +74,41 @@ export default class extends Controller { console.error("Polling error:", error); } } + + frameElement() { + if (this.hasFrameIdValue) { + return document.getElementById(this.frameIdValue); + } + + if (this.element.tagName.toLowerCase() === "turbo-frame") { + return this.element; + } + + return this.element.closest("turbo-frame"); + } + + cssEscape(value) { + if (window.CSS?.escape) return CSS.escape(value); + + return value.replaceAll('"', '\\"'); + } + + syncPollingAttributes(newFrame) { + const pollingUrl = newFrame.getAttribute("data-polling-url-value"); + const pollingInterval = newFrame.getAttribute( + "data-polling-interval-value", + ); + + if (pollingUrl) { + this.element.setAttribute("data-polling-url-value", pollingUrl); + } else { + this.element.removeAttribute("data-polling-url-value"); + } + + if (pollingInterval) { + this.element.setAttribute("data-polling-interval-value", pollingInterval); + } else { + this.element.removeAttribute("data-polling-interval-value"); + } + } } diff --git a/app/jobs/sophtron_initial_load_job.rb b/app/jobs/sophtron_initial_load_job.rb new file mode 100644 index 000000000..ca91a9c64 --- /dev/null +++ b/app/jobs/sophtron_initial_load_job.rb @@ -0,0 +1,20 @@ +class SophtronInitialLoadJob < ApplicationJob + queue_as :high_priority + + RETRY_DELAY = 10.seconds + MAX_ATTEMPTS = 30 + + def perform(sophtron_item, attempts_remaining: MAX_ATTEMPTS) + if sophtron_item.syncing? + if attempts_remaining.positive? + self.class.set(wait: RETRY_DELAY).perform_later(sophtron_item, attempts_remaining: attempts_remaining - 1) + else + Rails.logger.warn("SophtronInitialLoadJob - gave up waiting for SophtronItem #{sophtron_item.id} to finish syncing") + end + + return + end + + sophtron_item.sync_later + end +end diff --git a/app/jobs/sophtron_refresh_poll_job.rb b/app/jobs/sophtron_refresh_poll_job.rb new file mode 100644 index 000000000..1f5afb08a --- /dev/null +++ b/app/jobs/sophtron_refresh_poll_job.rb @@ -0,0 +1,76 @@ +class SophtronRefreshPollJob < ApplicationJob + queue_as :high_priority + + POLL_INTERVAL = 4.seconds + MAX_ATTEMPTS = 60 + + def perform(sophtron_account, job_id:, attempts_remaining: MAX_ATTEMPTS, sync: nil) + sophtron_item = sophtron_account.sophtron_item + provider = sophtron_item.sophtron_provider + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + job = Provider::Sophtron.response_data!(provider.get_job_information(job_id)) + sophtron_item.upsert_job_snapshot!(job) + + if Provider::Sophtron.job_requires_input?(job) + mark_requires_update!(sophtron_item, job_id) + elsif Provider::Sophtron.job_failed?(job) + sophtron_item.update!(last_connection_error: "Sophtron refresh failed") + elsif Provider::Sophtron.job_success?(job) || Provider::Sophtron.job_completed?(job) + import_transactions!(sophtron_account, provider, sync) + elsif attempts_remaining.to_i > 1 + self.class.set(wait: POLL_INTERVAL).perform_later( + sophtron_account, + job_id: job_id, + attempts_remaining: attempts_remaining.to_i - 1, + sync: sync + ) + else + sophtron_item.update!(last_connection_error: "Sophtron refresh did not finish before the polling timeout") + end + rescue Provider::Sophtron::Error => e + handle_provider_error!(sophtron_account.sophtron_item, e) + end + + private + + def import_transactions!(sophtron_account, provider, sync) + sophtron_item = sophtron_account.sophtron_item + result = SophtronItem::Importer.new(sophtron_item, sophtron_provider: provider, sync: sync) + .import_transactions_after_refresh(sophtron_account) + + unless result[:success] + attributes = { last_connection_error: result[:error] } + attributes[:status] = :requires_update if result[:requires_update] + sophtron_item.update!(attributes) + return + end + + SophtronAccount::Processor.new(sophtron_account.reload).process + + account = sophtron_account.current_account + return unless account + + account.sync_later( + parent_sync: sync, + window_start_date: sync&.window_start_date, + window_end_date: sync&.window_end_date + ) + end + + def mark_requires_update!(sophtron_item, job_id) + sophtron_item.update!( + status: :requires_update, + current_job_id: job_id, + last_connection_error: "Sophtron refresh requires MFA" + ) + end + + def handle_provider_error!(sophtron_item, error) + requires_update = error.error_type.in?([ :unauthorized, :access_forbidden ]) + attributes = { last_connection_error: error.message } + attributes[:status] = :requires_update if requires_update + sophtron_item.update!(attributes) + Rails.logger.error "SophtronRefreshPollJob - Sophtron API error for item #{sophtron_item.id}: #{error.message}" + end +end diff --git a/app/models/family/sophtron_connectable.rb b/app/models/family/sophtron_connectable.rb index f2f241dad..cc1fb3b0c 100644 --- a/app/models/family/sophtron_connectable.rb +++ b/app/models/family/sophtron_connectable.rb @@ -18,12 +18,14 @@ module Family::SophtronConnectable base_url: base_url ) - sophtron_item.sync_later - sophtron_item end def has_sophtron_credentials? sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).exists? end + + def configured_sophtron_item + sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.first + end end diff --git a/app/models/provider/sophtron.rb b/app/models/provider/sophtron.rb index 69526524c..69261133f 100644 --- a/app/models/provider/sophtron.rb +++ b/app/models/provider/sophtron.rb @@ -1,373 +1,473 @@ # Sophtron API client for account aggregation. # -# This provider implements the Sophtron API v2 for fetching bank account data, -# transactions, and balances. It uses HMAC-SHA256 authentication for secure -# API requests. -# -# The Sophtron API organizes data hierarchically: -# - Customers (identified by customer_id) -# - Accounts (identified by account_id within a customer) -# - Transactions (identified by transaction_id within an account) -# -# @example Initialize a Sophtron provider -# provider = Provider::Sophtron.new( -# "user123", -# "base64_encoded_access_key", -# base_url: "https://api.sophtron.com/api/v2" -# ) -# -# @see https://www.sophtron.com Documentation for Sophtron API +# Sophtron uses two API shapes: +# - V2 REST endpoints for customer provisioning. +# - V1 RPC-style endpoints for institution connection, jobs, MFA, accounts, and transactions. class Provider::Sophtron < Provider include HTTParty - headers "User-Agent" => "Sure Finance So Client" + DEFAULT_BASE_URL = "https://api.sophtron.com/api" + USER_AGENT = "Sure Finance Sophtron Client" + FAILURE_JOB_STATUSES = %w[Completed Timeout Failed Failure Error].freeze + + headers "User-Agent" => USER_AGENT default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + Error = Class.new(Provider::Error) do + attr_reader :error_type + + def initialize(message, error_type = :unknown, details: nil) + @error_type = error_type + super(message, details: details) + end + end + attr_reader :user_id, :access_key, :base_url - # Initializes a new Sophtron API client. - # - # @param user_id [String] Sophtron User ID for authentication - # @param access_key [String] Base64-encoded Sophtron Access Key - # @param base_url [String] Base URL for the Sophtron API (defaults to production) - def initialize(user_id, access_key, base_url: "https://api.sophtron.com/api/v2") + def initialize(user_id, access_key, base_url: DEFAULT_BASE_URL) @user_id = user_id @access_key = access_key - @base_url = base_url + @base_url = normalize_base_url(base_url) super() end - # Fetches all accounts across all customers for this Sophtron user. - # - # This method: - # 1. Fetches the list of customer IDs - # 2. For each customer, fetches their accounts - # 3. Normalizes and deduplicates the account data - # 4. Returns a combined list of all accounts - # - # @return [Hash] Account data with keys: - # - :accounts [Array] Array of account objects - # - :total [Integer] Total number of accounts - # @raise [Provider::Error] if the API request fails - # @example - # result = provider.get_accounts - # # => { accounts: [{id: "123", account_name: "Checking", ...}], total: 1 } - def get_accounts + def auth_header_for(method, api_path) + auth_path = self.class.auth_path(api_path) + plain_key = "#{method.to_s.upcase}\n#{auth_path}" + key_bytes = Base64.decode64(access_key.to_s) + raise ArgumentError, "decoded key is empty" if key_bytes.blank? + signature = OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), key_bytes, plain_key) + "FIApiAUTH:#{user_id}:#{Base64.strict_encode64(signature)}:#{auth_path}" + rescue ArgumentError => e + raise Error.new("Invalid Sophtron Access Key: #{e.message}", :invalid_access_key) + end + + def self.auth_path(api_path) + path = URI.parse(api_path.to_s).path + last_segment = path.to_s.split("/").last.to_s + "/#{last_segment}".downcase + rescue URI::InvalidURIError + last_segment = api_path.to_s.split("?").first.to_s.split("/").last.to_s + "/#{last_segment}".downcase + end + + def self.job_success?(job) + job = job.with_indifferent_access + job[:SuccessFlag] == true || job[:success_flag] == true || job[:LastStatus].to_s == "AccountsReady" || job[:last_status].to_s == "AccountsReady" + end + + def self.job_failed?(job) + job = job.with_indifferent_access + success_flag = job.key?(:SuccessFlag) ? job[:SuccessFlag] : job[:success_flag] + last_status = job[:LastStatus] || job[:last_status] + success_flag == false && failure_job_status?(last_status) + end + + def self.job_completed?(job) + job = job.with_indifferent_access + (job[:LastStatus] || job[:last_status]).to_s == "Completed" && !job_failed?(job) + end + + def self.failure_job_status?(last_status) + status = last_status.to_s + FAILURE_JOB_STATUSES.include?(status) || status.match?(/timeout|fail|error/i) + end + + def self.job_requires_input?(job) + job = job.with_indifferent_access + job[:SecurityQuestion].present? || + job[:security_question].present? || + job[:TokenMethod].present? || + job[:token_method].present? || + job_token_input_required?(job) || + job[:TokenRead].present? || + job[:token_read].present? || + job[:CaptchaImage].present? || + job[:captcha_image].present? + end + + def self.job_token_input_required?(job) + job = job.with_indifferent_access + token_input = job[:TokenInput] || job[:token_input] + token_input.blank? && ( + job[:TokenSentFlag] == true || + job[:token_sent_flag] == true || + job[:TokenInputName].present? || + job[:token_input_name].present? || + job[:LastStep].to_s == "TokenInput" || + job[:last_step].to_s == "TokenInput" + ) + end + + def self.parse_json_array(value) + return [] if value.blank? + return value if value.is_a?(Array) + + parsed = JSON.parse(value.to_s) + parsed.is_a?(Array) ? parsed : Array(parsed) + rescue JSON::ParserError + Array(value) + end + + def self.response_data!(response) + return response unless response.respond_to?(:success?) && response.respond_to?(:data) + return response.data if response.success? + + raise response.error || Error.new("Sophtron provider response did not include data", :invalid_response) + end + + # GET /api/Institution/HealthCheckAuth + def health_check_auth with_provider_response do - # fetching accounts for sophtron - # Obtain customer IDs using a dedicated helper - customer_ids = get_customer_ids - - all_accounts = [] - customer_ids.each do |cust_id| - begin - accounts_resp = get_customer_accounts(cust_id) - - # `handle_response` returns parsed JSON (hash/array) so normalize - raw_accounts = if accounts_resp.is_a?(Hash) && accounts_resp[:accounts].is_a?(Array) - accounts_resp[:accounts] - elsif accounts_resp.is_a?(Array) - accounts_resp - else - [] - end - - normalized = raw_accounts.map { |a| a.transform_keys { |k| k.to_s.underscore }.with_indifferent_access } - - # Ensure each account has a customer_id set - normalized.each do |acc| - # check common variants that may already exist - existing = acc[:customer_id] - acc[:customer_id] = cust_id.to_s if existing.blank? - end - - all_accounts.concat(normalized) - rescue Provider::Error => e - Rails.logger.warn("Failed to fetch accounts for customer #{cust_id}: #{e.message}") - rescue => e - Rails.logger.warn("Unexpected error fetching accounts for customer #{cust_id}: #{e.class} #{e.message}") - end - end - - # Deduplicate by id where present - unique_accounts = all_accounts.uniq { |a| a[:id].to_s } - - { accounts: unique_accounts, total: unique_accounts.length } + request(:get, "/Institution/HealthCheckAuth", parse_json: false) end end - # Fetches transactions for a specific account. - # - # Retrieves transaction history for a given account within a date range. - # If no end date is provided, defaults to tomorrow to include today's transactions. - # - # @param customer_id [String] Sophtron customer ID - # @param account_id [String] Sophtron account ID - # @param start_date [Date, nil] Start date for transaction history (optional) - # @param end_date [Date, nil] End date for transaction history (defaults to tomorrow) - # @return [Hash] Transaction data with keys: - # - :transactions [Array] Array of transaction objects - # - :total [Integer] Total number of transactions - # @raise [Provider::Error] if the API request fails - # @example - # result = provider.get_account_transactions("cust123", "acct456", start_date: 30.days.ago) - # # => { transactions: [{id: "tx1", amount: -50.00, ...}], total: 25 } - def get_account_transactions(customer_id, account_id, start_date: nil, end_date: nil) + # GET /api/v2/customers + def list_customers with_provider_response do - query_params = {} - - if start_date - query_params[:startDate] = start_date.to_date - end - if end_date - query_params[:endDate] = end_date.to_date - else - query_params[:endDate] = Date.tomorrow - end - - path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts/#{ERB::Util.url_encode(account_id.to_s)}/transactions" - path += "?#{URI.encode_www_form(query_params)}" unless query_params.empty? - url = "#{@base_url}#{path}" - - response = self.class.get( - url, - headers: auth_headers(url: url, http_method: "GET") - ) - - parsed = handle_response(response) - # Normalize transactions response into { transactions: [...], total: N } - if parsed.is_a?(Array) - txs = parsed.map { |tx| tx.transform_keys { |k| k.to_s.underscore }.with_indifferent_access } - mapped = txs.map { |tx| map_transaction(tx, account_id) } - { transactions: mapped, total: mapped.length } - elsif parsed.is_a?(Hash) - if parsed[:transactions].is_a?(Array) - txs = parsed[:transactions].map { |tx| tx.transform_keys { |k| k.to_s.underscore }.with_indifferent_access } - mapped = txs.map { |tx| map_transaction(tx, account_id) } - parsed[:transactions] = mapped - parsed[:total] = parsed[:total] || mapped.length - parsed - else - # Single transaction object -> wrap and map - single = parsed.transform_keys { |k| k.to_s.underscore }.with_indifferent_access - mapped = map_transaction(single, account_id) - { transactions: [ mapped ], total: 1 } - end - else - { transactions: [], total: 0 } - end + parsed = request(:get, "/v2/customers") + extract_array_response(parsed, :customers, :Customers) end end - # Fetches the current balance for a specific account. - # - # @param customer_id [String] Sophtron customer ID - # @param account_id [String] Sophtron account ID - # @return [Hash] Balance data with keys: - # - :balance [Hash] Balance information - # - :amount [Numeric] Current balance amount - # - :currency [String] Currency code (defaults to "USD") - # @raise [Provider::Error] if the API request fails - # @example - # result = provider.get_account_balance("cust123", "acct456") - # # => { balance: { amount: 1000.00, currency: "USD" } } - def get_account_balance(customer_id, account_id) + # POST /api/v2/customers + def create_customer(unique_id:, name:, source: "Sure") with_provider_response do - path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts/#{ERB::Util.url_encode(account_id.to_s)}" - url = "#{@base_url}#{path}" - - response = self.class.get( - url, - headers: auth_headers(url: url, http_method: "GET") - ) - - parsed = handle_response(response) - - # Normalize balance information into { balance: { amount: N, currency: "XXX" } } - # Sophtron returns balance as flat fields: Balance and BalanceCurrency (capitalized) - # After JSON symbolization these become: :Balance and :BalanceCurrency - balance_amount = parsed[:Balance] || parsed[:balance] - balance_currency = parsed[:BalanceCurrency] || parsed[:balance_currency] - - if parsed.is_a?(Hash) && balance_amount.present? - result = { - balance: { - amount: balance_amount, - currency: balance_currency.presence || "USD" - } + request( + :post, + "/v2/customers", + body: { + UniqueID: unique_id, + Name: name, + Source: source } - else - result = { balance: { amount: 0, currency: "USD" } } - end - result + ) end end + # POST /api/Institution/GetInstitutionByName + def search_institutions(institution_name) + with_provider_response do + parsed = request( + :post, + "/Institution/GetInstitutionByName", + body: { InstitutionName: institution_name.to_s } + ) + extract_array_response(parsed, :institutions, :Institutions) + end + end + + # POST /api/UserInstitution/GetUserInstitutionsByUser + def list_user_institutions + with_provider_response do + parsed = request( + :post, + "/UserInstitution/GetUserInstitutionsByUser", + body: { UserID: user_id } + ) + extract_array_response(parsed, :user_institutions, :UserInstitutions) + end + end + + # POST /api/UserInstitution/CreateUserInstitution + def create_user_institution(institution_id:, username:, password:, pin: "") + with_provider_response do + request( + :post, + "/UserInstitution/CreateUserInstitution", + body: { + UserID: user_id, + InstitutionID: institution_id, + UserName: username, + Password: password, + PIN: pin.to_s + } + ) + end + end + + # POST /api/Job/GetJobInformationByID + def get_job_information(job_id) + with_provider_response do + fetch_job_information(job_id) + end + end + + # POST /api/Job/UpdateJobSecurityAnswer + def update_job_security_answer(job_id, answers) + security_answer = answers.is_a?(String) ? answers : Array(answers).to_json + + with_provider_response do + request( + :post, + "/Job/UpdateJobSecurityAnswer", + body: { JobID: job_id, SecurityAnswer: security_answer } + ) + end + end + + # POST /api/Job/UpdateJobTokenInput + def update_job_token_input(job_id, token_choice: nil, token_input: nil, verify_phone_flag: nil) + with_provider_response do + request( + :post, + "/Job/UpdateJobTokenInput", + body: { + JobID: job_id, + TokenChoice: token_choice, + TokenInput: token_input, + VerifyPhoneFlag: verify_phone_flag + } + ) + end + end + + # POST /api/Job/UpdateJobCaptcha + def update_job_captcha(job_id, captcha_input) + with_provider_response do + request( + :post, + "/Job/UpdateJobCaptcha", + body: { JobID: job_id, CaptchaInput: captcha_input } + ) + end + end + + # POST /api/UserInstitution/GetUserInstitutionAccounts + def get_user_institution_accounts(user_institution_id) + with_provider_response do + fetch_user_institution_accounts(user_institution_id) + end + end + + def get_accounts(user_institution_id) + with_provider_response do + accounts = fetch_user_institution_accounts(user_institution_id) + normalized = accounts.map { |account| normalize_account(account, user_institution_id: user_institution_id) } + { accounts: normalized, total: normalized.size } + end + end + + # POST /api/UserInstitutionAccount/RefreshUserInstitutionAccount + def refresh_account(account_id) + with_provider_response do + request( + :post, + "/UserInstitutionAccount/RefreshUserInstitutionAccount", + body: { AccountID: account_id } + ) + end + end + + # POST /api/Transaction/GetTransactionsByTransactionDate + def get_account_transactions(account_id, start_date: nil, end_date: nil) + with_provider_response do + parsed = request( + :post, + "/Transaction/GetTransactionsByTransactionDate", + body: { + AccountID: account_id, + StartDate: (start_date || 120.days.ago).to_date.to_s, + EndDate: (end_date || Date.tomorrow).to_date.to_s + } + ) + + raw_transactions = extract_array_response(parsed, :transactions, :Transactions) + transactions = raw_transactions.map { |transaction| normalize_transaction(transaction, account_id) } + + { transactions: transactions, total: transactions.size } + end + end + + def poll_job(job_id, **) + get_job_information(job_id) + end + private - def sophtron_auth_code(url:, http_method:) - require "base64" - require "openssl" - # sophtron auth code generation - # Parse path portion of the URL and use the last "/..." segment (matching upstream examples) - uri = URI.parse(url) - # Sign the last path segment (lowercased) and include the query string if present - path = (uri.path || "").downcase - idx = path.rindex("/") - last_seg = idx ? path[idx..-1] : path - query_str = uri.query ? "?#{uri.query.to_s.downcase}" : "" - auth_path = "#{last_seg}#{query_str}" - # Build the plain text to sign: "METHOD\n/auth_path" - plain_key = "#{http_method.to_s.upcase}\n#{auth_path}" - # Decode the base64 access key and compute HMAC-SHA256 - begin - key_bytes = Base64.decode64(@access_key.to_s) - rescue => decode_err - Rails.logger.error("[sophtron_auth_code] Failed to decode access_key: #{decode_err.class}: #{decode_err.message}") - raise - end - signature = OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), key_bytes, plain_key) - sig_b64_str = Base64.strict_encode64(signature) - auth_code = "FIApiAUTH:#{@user_id}:#{sig_b64_str}:#{auth_path}" - auth_code + def default_error_transformer(error) + return error if error.is_a?(Error) + + super end - def auth_headers(url:, http_method:) + def fetch_job_information(job_id) + request( + :post, + "/Job/GetJobInformationByID", + body: { JobID: job_id } + ) + end + + def fetch_user_institution_accounts(user_institution_id) + parsed = request( + :post, + "/UserInstitution/GetUserInstitutionAccounts", + body: { UserInstitutionID: user_institution_id } + ) + extract_array_response(parsed, :accounts, :Accounts) + end + + def extract_array_response(parsed, *keys) + return parsed if parsed.is_a?(Array) + return [] if parsed.respond_to?(:empty?) && parsed.empty? + + if parsed.respond_to?(:with_indifferent_access) + parsed = parsed.with_indifferent_access + keys.each do |key| + return Array(parsed[key]) if parsed.key?(key) + end + end + + raise Error.new("Invalid Sophtron response format", :invalid_response, details: parsed) + end + + def request(method, api_path, body: nil, parse_json: true) + options = { headers: auth_headers(method: method, api_path: api_path) } + options[:body] = body.to_json if body + + response = self.class.public_send(method, "#{base_url}#{api_path}", options) + handle_response(response, parse_json: parse_json) + rescue Error + raise + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise Error.new("Sophtron request failed: #{e.message}", :request_failed) + rescue StandardError => e + raise Error.new("Sophtron request failed: #{e.message}", :request_failed) + end + + def auth_headers(method:, api_path:) { - "Authorization" => sophtron_auth_code(url: url, http_method: http_method), + "Authorization" => auth_header_for(method, api_path), "Content-Type" => "application/json", "Accept" => "application/json" } end - # Fetch list of customer IDs by calling GET /customers and extracting identifier fields - def get_customer_ids - url = "#{@base_url}/customers" - response = self.class.get( - url, - headers: auth_headers(url: url, http_method: "GET") - ) - parsed = handle_response(response) - ids = [] - if parsed.is_a?(Array) - ids = parsed.map do |r| - next unless r.is_a?(Hash) - # Find a key that likely contains the customer id (handles :CustomerID, :customerID, :customer_id, :ID, :id) - key = r.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } || - r.keys.find { |k| k.to_s.downcase == "id" } - r[key] - end.compact - elsif parsed.is_a?(Hash) - if parsed[:customers].is_a?(Array) - ids = parsed[:customers].map do |r| - next unless r.is_a?(Hash) - key = r.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } || - r.keys.find { |k| k.to_s.downcase == "id" } - r[key] - end.compact - else - key = parsed.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } || - parsed.keys.find { |k| k.to_s.downcase == "id" } - ids = [ parsed[key] ].compact - end + def handle_response(response, parse_json: true) + body = response.body.to_s + + case response.code.to_i + when 200, 201, 204 + return {} if body.strip.blank? + + parse_json ? JSON.parse(body, symbolize_names: true) : parse_optional_json(body) + when 400 + raise Error.new("Bad request to Sophtron API: #{body}", :bad_request, details: body) + when 401 + raise Error.new("Invalid Sophtron User ID or Access Key", :unauthorized, details: body) + when 403 + raise Error.new("Access forbidden by Sophtron", :access_forbidden, details: body) + when 404 + raise Error.new("Sophtron resource not found", :not_found, details: body) + when 429 + raise Error.new("Sophtron rate limit exceeded. Please try again later.", :rate_limited, details: body) + else + raise Error.new( + "Sophtron API request failed: #{response.code} #{response.message} - #{body}", + :fetch_failed, + details: body + ) + end + rescue JSON::ParserError => e + raise Error.new("Invalid JSON response from Sophtron API: #{e.message}", :invalid_response, details: body) + end + + def parse_optional_json(body) + JSON.parse(body, symbolize_names: true) + rescue JSON::ParserError + body + end + + def normalize_base_url(value) + url = value.presence || DEFAULT_BASE_URL + url = url.to_s.chomp("/") + url = url.delete_suffix("/v2") if url.end_with?("/v2") + + parsed = URI.parse(url) + parsed.path.to_s.end_with?("/api") ? url : "#{url}/api" + rescue URI::InvalidURIError + DEFAULT_BASE_URL + end + + def normalize_account(account, user_institution_id:) + account = account.with_indifferent_access + account_id = first_present(account, :AccountID, :account_id, :id) + account_name = first_present(account, :AccountName, :account_name, :name) + account_number = first_present(account, :AccountNumber, :account_number) + currency = first_present(account, :BalanceCurrency, :balance_currency, :Currency, :currency).presence || "USD" + + { + id: account_id, + account_id: account_id, + account_name: account_name, + name: account_name, + account_type: first_present(account, :AccountType, :account_type, :type).presence || "unknown", + sub_type: first_present(account, :AccountSubType, :account_sub_type, :SubType, :sub_type).presence || "unknown", + balance: first_present(account, :AccountBalance, :account_balance, :Balance, :balance), + balance_currency: currency, + currency: currency, + account_number_mask: mask_account_number(account_number), + status: first_present(account, :AccountStatus, :account_status, :Status, :status).presence || "active", + user_institution_id: user_institution_id, + institution_name: first_present(account, :InstitutionName, :institution_name), + raw_payload: account.to_h + }.with_indifferent_access + end + + def normalize_transaction(transaction, account_id) + transaction = transaction.with_indifferent_access + + { + id: first_present(transaction, :TransactionID, :TransactionId, :transaction_id, :transactionId, :ID, :id), + accountId: account_id, + type: first_present(transaction, :Type, :type).presence || "unknown", + status: first_present(transaction, :Status, :status).presence || "completed", + amount: first_present(transaction, :Amount, :amount).presence || 0, + currency: first_present(transaction, :Currency, :currency).presence || "USD", + date: first_present(transaction, :TransactionDate, :transaction_date, :Date, :date), + merchant: first_present(transaction, :Merchant, :merchant).presence || extract_merchant(first_present(transaction, :Description, :description)).presence || "", + description: first_present(transaction, :Description, :description).presence || "" + }.with_indifferent_access + end + + def first_present(hash, *keys) + keys.each do |key| + value = hash[key] + return value if value.present? end - # Normalize to strings and unique (avoid destructive methods that may return nil) - ids = ids.map(&:to_s).compact.uniq - ids + nil end - # Fetch accounts for a specific customer via GET /customers/:customer_id/accounts - def get_customer_accounts(customer_id) - path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts" - url = "#{@base_url}#{path}" - response = self.class.get( - url, - headers: auth_headers(url: url, http_method: "GET") - ) - handle_response(response) - end + def mask_account_number(account_number) + return nil if account_number.blank? - # Map a normalized Sophtron transaction hash into our standard transaction shape - # Returns: { id, accountId, type, status, amount, currency, date, merchant, description } - def map_transaction(tx, account_id) - tx = tx.with_indifferent_access - { - id: tx[:transaction_id], - accountId: account_id, - type: tx[:type] || "unknown", - status: tx[:status] || "completed", - amount: tx[:amount] || 0.0, - currency: tx[:currency] || "USD", - date: tx[:transaction_date] || nil, - merchant: tx[:merchant] || extract_merchant(tx[:description]) ||"", - description: tx[:description] || "" - }.with_indifferent_access + last_four = account_number.to_s.gsub(/\s+/, "").last(4) + last_four.present? ? "****#{last_four}" : nil end def extract_merchant(line) return nil if line.nil? - line = line.strip + + line = line.to_s.strip return nil if line.empty? - # 1. Handle special bank fees and automated transactions if line =~ /INSUFFICIENT FUNDS FEE/i - return "Bank Fee: Insufficient Funds" + "Bank Fee: Insufficient Funds" elsif line =~ /OVERDRAFT PROTECTION/i - return "Bank Transfer: Overdraft Protection" + "Bank Transfer: Overdraft Protection" elsif line =~ /AUTO PAY WF HOME MTG/i - return "Wells Fargo Home Mortgage" + "Wells Fargo Home Mortgage" elsif line =~ /PAYDAY LOAN/i - return "Payday Loan" - end - - # 2. Refined CHECKCARD Pattern - # Logic: - # - Start after 'CHECKCARD XXXX ' - # - Capture everything (.+?) - # - STOP when we see: - # a) Two or more spaces (\s{2,}) - # b) A masked number (x{3,}) - # c) A pattern of [One Word] + [Space] + [State Code] (\s+\S+\s+[A-Z]{2}\b) - # The (\s+\S+) part matches the city, so we stop BEFORE it. - if line =~ /CHECKCARD \d{4}\s+(.+?)(?=\s{2,}|x{3,}|\s+\S+\s+[A-Z]{2}\b)/i - return $1.strip - end - - # 3. Handle standard purchase rows (e.g., EXXONMOBIL POS 12/08) - # Stops before date (MM/DD) or hash (#) - if line =~ /^(.+?)(?=\s+\d{2}\/\d{2}|\s+#)/ - name = $1.strip - return name.gsub(/\s+POS$/i, "").strip - end - - # 4. Fallback for other formats - line[0..25].strip - end - - def handle_response(response) - case response.code - when 200 - begin - JSON.parse(response.body, symbolize_names: true) - rescue JSON::ParserError => e - Rails.logger.error "Sophtron API: Invalid JSON response - #{e.message}" - raise Provider::Error.new("Invalid JSON response from Sophtron API", :invalid_response) - end - when 400 - Rails.logger.error "Sophtron API: Bad request - #{response.body}" - raise Provider::Error.new("Bad request to Sophtron API: #{response.body}", :bad_request) - when 401 - raise Provider::Error.new("Invalid User ID or Access key", :unauthorized) - when 403 - raise Provider::Error.new("Access forbidden - check your User ID and Access key permissions", :access_forbidden) - when 404 - raise Provider::Error.new("Resource not found", :not_found) - when 429 - raise Provider::Error.new("Rate limit exceeded. Please try again later.", :rate_limited) + "Payday Loan" + elsif line =~ /CHECKCARD \d{4}\s+(.+?)(?=\s{2,}|x{3,}|\s+\S+\s+[A-Z]{2}\b)/i + Regexp.last_match(1).strip + elsif line =~ /^(.+?)(?=\s+\d{2}\/\d{2}|\s+#)/ + Regexp.last_match(1).strip.gsub(/\s+POS$/i, "").strip else - Rails.logger.error "Sophtron API: Unexpected response - Code: #{response.code}, Body: #{response.body}" - raise Provider::Error.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed) + line[0..25].strip end end end diff --git a/app/models/provider/sophtron_adapter.rb b/app/models/provider/sophtron_adapter.rb index 155c6bb2c..144bdd482 100644 --- a/app/models/provider/sophtron_adapter.rb +++ b/app/models/provider/sophtron_adapter.rb @@ -22,7 +22,8 @@ class Provider::SophtronAdapter < Provider::Base new_account_path: ->(accountable_type, return_to) { Rails.application.routes.url_helpers.select_accounts_sophtron_items_path( accountable_type: accountable_type, - return_to: return_to + return_to: return_to, + connect_new_institution: true ) }, existing_account_path: ->(account_id) { @@ -45,7 +46,7 @@ class Provider::SophtronAdapter < Provider::Base return nil unless family.present? # Get family-specific credentials - sophtron_item = family.sophtron_items.where.not(user_id: nil, access_key: nil).first + sophtron_item = family.configured_sophtron_item return nil unless sophtron_item&.credentials_configured? Provider::Sophtron.new( @@ -88,10 +89,16 @@ class Provider::SophtronAdapter < Provider::Base end def institution_name - metadata = provider_account.institution_metadata + metadata = provider_account.institution_metadata || {} return nil unless metadata.present? - metadata["name"] || item&.institution_name + metadata_name = metadata["name"].presence || metadata["institution_name"].presence + return metadata_name if metadata_name.present? + + metadata_user_institution_id = metadata["user_institution_id"].presence || metadata["UserInstitutionID"].presence + return item&.institution_name if metadata_user_institution_id.present? && metadata_user_institution_id == item&.user_institution_id + + nil end def institution_url diff --git a/app/models/sophtron_account.rb b/app/models/sophtron_account.rb index f61af0eab..d6f111b3a 100644 --- a/app/models/sophtron_account.rb +++ b/app/models/sophtron_account.rb @@ -46,20 +46,37 @@ class SophtronAccount < ApplicationRecord def upsert_sophtron_snapshot!(account_snapshot) # Convert to symbol keys or handle both string and symbol keys snapshot = account_snapshot.with_indifferent_access + account_id = first_present(snapshot, :account_id, :id, :AccountID) + account_name = first_present(snapshot, :account_name, :name, :AccountName) + account_number = first_present(snapshot, :account_number, :AccountNumber) + currency = first_present(snapshot, :balance_currency, :currency, :BalanceCurrency, :Currency) + balance = first_present(snapshot, :balance, :account_balance, :AccountBalance, :Balance) + available_balance = first_present(snapshot, :"available-balance", :available_balance, :AvailableBalance) + account_type = first_present(snapshot, :account_type, :type, :AccountType) + account_sub_type = first_present(snapshot, :sub_type, :account_sub_type, :AccountSubType, :SubType) + last_updated = first_present(snapshot, :last_updated, :LastUpdated) + institution_name = first_present(snapshot, :institution_name, :InstitutionName).presence || sophtron_item&.institution_name + user_institution_id = first_present(snapshot, :user_institution_id, :UserInstitutionID).presence || sophtron_item&.user_institution_id # Map Sophtron field names to our field names assign_attributes( - name: snapshot[:account_name], - account_id: snapshot[:account_id], - currency: parse_currency(snapshot[:balance_currency]) || "USD", - balance: parse_balance(snapshot[:balance]), - available_balance: parse_balance(snapshot[:"available-balance"]), - account_type: snapshot["account_type"] || "unknown", - account_sub_type: snapshot["sub_type"] || "unknown", - last_updated: parse_balance_date(snapshot[:"last_updated"]), + name: account_name, + account_id: account_id, + currency: parse_currency(currency) || "USD", + balance: parse_balance(balance), + available_balance: parse_balance(available_balance), + account_type: account_type.presence || "unknown", + account_sub_type: account_sub_type.presence || "unknown", + last_updated: parse_balance_date(last_updated), + account_status: first_present(snapshot, :account_status, :status, :AccountStatus, :Status), + account_number_mask: snapshot[:account_number_mask].presence || mask_account_number(account_number), + institution_metadata: { + name: institution_name, + user_institution_id: user_institution_id + }.compact, raw_payload: account_snapshot, - customer_id: snapshot["customer_id"], - member_id: snapshot["member_id"] + customer_id: first_present(snapshot, :customer_id, :CustomerID) || customer_id, + member_id: first_present(snapshot, :member_id, :MemberID) || member_id ) save! @@ -127,4 +144,20 @@ class SophtronAccount < ApplicationRecord return if balance.present? || available_balance.present? errors.add(:base, "Sophtron account must have either current or available balance") end + + def first_present(hash, *keys) + keys.each do |key| + value = hash[key] + return value if value.present? + end + + nil + end + + def mask_account_number(account_number) + return nil if account_number.blank? + + last_four = account_number.to_s.gsub(/\s+/, "").last(4) + last_four.present? ? "****#{last_four}" : nil + end end diff --git a/app/models/sophtron_item.rb b/app/models/sophtron_item.rb index 52a5f3cb7..dc278fe65 100644 --- a/app/models/sophtron_item.rb +++ b/app/models/sophtron_item.rb @@ -14,6 +14,9 @@ class SophtronItem < ApplicationRecord include Syncable, Provided, Unlinking + INITIAL_LOAD_LOOKBACK_DAYS = 120 + MAX_TRANSACTION_HISTORY_YEARS = 3 + enum :status, { good: "good", requires_update: "requires_update" }, default: :good # Helper to detect if ActiveRecord Encryption is configured for this app. @@ -66,15 +69,15 @@ class SophtronItem < ApplicationRecord # # @return [Hash] Import results with counts of accounts and transactions imported # @raise [StandardError] if the Sophtron provider is not configured - # @raise [Provider::Error] if the Sophtron API returns an error - def import_latest_sophtron_data + # @raise [Provider::Sophtron::Error] if the Sophtron API returns an error + def import_latest_sophtron_data(sync: nil) provider = sophtron_provider unless provider Rails.logger.error "SophtronItem #{id} - Cannot import: Sophtron provider is not configured (missing API key)" raise StandardError.new("Sophtron provider is not configured") end - SophtronItem::Importer.new(self, sophtron_provider: provider).import + SophtronItem::Importer.new(self, sophtron_provider: provider, sync: sync).import rescue => e Rails.logger.error "SophtronItem #{id} - Failed to import data: #{e.message}" raise @@ -122,6 +125,24 @@ class SophtronItem < ApplicationRecord results end + def start_initial_load_later + active_sync = syncs.visible.ordered.first + + sync_later(window_start_date: initial_load_window_start_date) + + return unless active_sync&.reload&.syncing? + + SophtronInitialLoadJob.set(wait: SophtronInitialLoadJob::RETRY_DELAY).perform_later(self) + end + + def initial_load_window_start_date + configured_start = sync_start_date&.to_date + default_start = INITIAL_LOAD_LOOKBACK_DAYS.days.ago.to_date + max_history_start = MAX_TRANSACTION_HISTORY_YEARS.years.ago.to_date + + [ configured_start || default_start, max_history_start ].max + end + def upsert_sophtron_snapshot!(accounts_snapshot) assign_attributes( raw_payload: accounts_snapshot @@ -130,6 +151,118 @@ class SophtronItem < ApplicationRecord save! end + def ensure_customer!(provider: sophtron_provider) + return customer_id if customer_id.present? + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + matching_customer = find_matching_customer(Provider::Sophtron.response_data!(provider.list_customers)) + customer_payload = matching_customer || Provider::Sophtron.response_data!( + provider.create_customer( + unique_id: generated_customer_unique_id, + name: generated_customer_name, + source: "Sure" + ) + ) + + # Some Sophtron endpoints may return an empty body on success; re-list to find + # the customer we just created if the create response does not include an id. + if extract_customer_id(customer_payload).blank? + customer_payload = find_matching_customer(Provider::Sophtron.response_data!(provider.list_customers)) + end + + extracted_customer_id = extract_customer_id(customer_payload) + raise Provider::Sophtron::Error.new("Sophtron customer response did not include CustomerID", :invalid_response) if extracted_customer_id.blank? + + update!( + customer_id: extracted_customer_id, + customer_name: extract_customer_name(customer_payload).presence || generated_customer_name, + raw_customer_payload: customer_payload + ) + + customer_id + end + + def connected_to_institution? + user_institution_id.present? && current_job_id.blank? && good? && !failed_connection_job? + end + + def failed_connection_job? + payload = raw_job_payload || {} + payload = payload.with_indifferent_access if payload.respond_to?(:with_indifferent_access) + + success_flag = if payload.respond_to?(:key?) && payload.key?(:SuccessFlag) + payload[:SuccessFlag] + elsif payload.respond_to?(:key?) + payload[:success_flag] + end + + last_status = job_status.presence || + (payload[:LastStatus] if payload.respond_to?(:[])) || + (payload[:last_status] if payload.respond_to?(:[])) + + success_flag == false && Provider::Sophtron.failure_job_status?(last_status) + end + + def upsert_job_snapshot!(job_payload) + job_payload = job_payload.with_indifferent_access + + update!( + job_status: job_payload[:LastStatus] || job_payload[:last_status], + raw_job_payload: job_payload + ) + end + + def fetch_remote_accounts(force: false) + cache_key = "sophtron_accounts_#{family.id}_#{id}_#{user_institution_id}" + cached = Rails.cache.read(cache_key) + return cached if cached.present? && !force + + accounts_data = Provider::Sophtron.response_data!(sophtron_provider.get_accounts(user_institution_id)) + accounts = accounts_data[:accounts] || [] + Rails.cache.write(cache_key, accounts, expires_in: 5.minutes) + persist_remote_sophtron_accounts(accounts) + accounts + end + + def persist_remote_sophtron_accounts(accounts) + Array(accounts).each do |account_data| + account_data = account_data.with_indifferent_access + next if account_data[:account_name].blank? + + upsert_sophtron_account(account_data) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn("Skipping Sophtron account #{self.class.external_account_id(account_data)}: #{e.message}") + end + end + + def reject_already_linked(accounts) + linked_account_ids = sophtron_accounts.joins(:account_provider).pluck(:account_id).map(&:to_s) + Array(accounts).reject { |account| linked_account_ids.include?(self.class.external_account_id(account).to_s) } + end + + def upsert_sophtron_account(account_data) + sophtron_accounts.find_or_initialize_by( + account_id: self.class.external_account_id(account_data).to_s + ).tap do |sophtron_account| + sophtron_account.upsert_sophtron_snapshot!(account_data) + end + end + + def build_mfa_challenge(job) + job = job.with_indifferent_access + { + security_questions: Provider::Sophtron.parse_json_array(job[:SecurityQuestion] || job[:security_question]), + token_methods: Provider::Sophtron.parse_json_array(job[:TokenMethod] || job[:token_method]), + token_sent: Provider::Sophtron.job_token_input_required?(job), + token_read: job[:TokenRead] || job[:token_read], + captcha_image: job[:CaptchaImage] || job[:captcha_image] + } + end + + def self.external_account_id(account_data) + account_data.with_indifferent_access[:account_id] || account_data.with_indifferent_access[:id] + end + def has_completed_initial_setup? # Setup is complete if we have any linked accounts accounts.any? @@ -167,6 +300,10 @@ class SophtronItem < ApplicationRecord institution_name.presence || institution_domain.presence || name end + def provider_display_name + I18n.t("sophtron_items.defaults.name", default: "Sophtron Connection") + end + def connected_institutions # Get unique institutions from all accounts sophtron_accounts.includes(:account) @@ -193,6 +330,40 @@ class SophtronItem < ApplicationRecord end def effective_base_url - base_url.presence || "https://api.sophtron.com/api/v2" + base_url.presence || Provider::Sophtron::DEFAULT_BASE_URL end + + def generated_customer_unique_id + "sure-family-#{family.id}" + end + + def generated_customer_name + "Sure family #{family.id}" + end + + private + + def find_matching_customer(customers) + customers = Array(customers) + + customers.find do |customer| + extract_customer_id(customer).to_s == generated_customer_unique_id + end || customers.find do |customer| + extract_customer_name(customer).to_s == generated_customer_name + end + end + + def extract_customer_id(customer_payload) + return nil unless customer_payload.respond_to?(:with_indifferent_access) + + customer_payload = customer_payload.with_indifferent_access + customer_payload[:CustomerID] || customer_payload[:customer_id] || customer_payload[:id] + end + + def extract_customer_name(customer_payload) + return nil unless customer_payload.respond_to?(:with_indifferent_access) + + customer_payload = customer_payload.with_indifferent_access + customer_payload[:CustomerName] || customer_payload[:customer_name] || customer_payload[:name] + end end diff --git a/app/models/sophtron_item/importer.rb b/app/models/sophtron_item/importer.rb index dabf0f316..7a58308e6 100644 --- a/app/models/sophtron_item/importer.rb +++ b/app/models/sophtron_item/importer.rb @@ -14,15 +14,19 @@ require "set" # explicitly connected to Maybe Accounts). This allows users to selectively # import accounts of their choosing. class SophtronItem::Importer - attr_reader :sophtron_item, :sophtron_provider + INCREMENTAL_SYNC_BUFFER_DAYS = 60 + + attr_reader :sophtron_item, :sophtron_provider, :sync # Initializes a new importer. # # @param sophtron_item [SophtronItem] The Sophtron item to import data for # @param sophtron_provider [Provider::Sophtron] Configured Sophtron API client - def initialize(sophtron_item, sophtron_provider:) + # @param sync [Sync, nil] Optional sync record whose window should guide import scope + def initialize(sophtron_item, sophtron_provider:, sync: nil) @sophtron_item = sophtron_item @sophtron_provider = sophtron_provider + @sync = sync end # Performs the complete import process for this Sophtron item. @@ -48,6 +52,25 @@ class SophtronItem::Importer # # accounts_failed: 0, transactions_imported: 150, transactions_failed: 0 } def import Rails.logger.info "SophtronItem::Importer - Starting import for item #{sophtron_item.id}" + unless sophtron_item.user_institution_id.present? + error_message = "Sophtron institution connection is incomplete" + Rails.logger.warn "SophtronItem::Importer - Item #{sophtron_item.id} has no Sophtron UserInstitutionID" + sophtron_item.update!( + status: :requires_update, + last_connection_error: error_message + ) + + return { + success: false, + error: error_message, + accounts_updated: 0, + accounts_created: 0, + accounts_failed: 0, + transactions_imported: 0, + transactions_failed: 0 + } + end + # Step 1: Fetch all accounts from Sophtron accounts_data = fetch_accounts_data unless accounts_data @@ -124,6 +147,7 @@ class SophtronItem::Importer transactions_imported += result[:transactions_count] else transactions_failed += 1 + break if result[:requires_update] end rescue => e transactions_failed += 1 @@ -144,16 +168,16 @@ class SophtronItem::Importer } end + def import_transactions_after_refresh(sophtron_account) + fetch_and_store_transactions(sophtron_account, refresh: false) + end + private def fetch_accounts_data begin - accounts_data = sophtron_provider.get_accounts - # Extract data from Provider::Response object if needed - if accounts_data.respond_to?(:data) - accounts_data = accounts_data.data - end - rescue Provider::Error => e + accounts_data = Provider::Sophtron.response_data!(sophtron_provider.get_accounts(sophtron_item.user_institution_id)) + rescue Provider::Sophtron::Error => e # Handle authentication errors by marking item as requiring update if e.error_type == :unauthorized || e.error_type == :access_forbidden begin @@ -245,34 +269,41 @@ class SophtronItem::Importer # - :success [Boolean] Whether the fetch was successful # - :transactions_count [Integer] Number of transactions fetched # - :error [String, nil] Error message if failed - def fetch_and_store_transactions(sophtron_account) + def fetch_and_store_transactions(sophtron_account, refresh: true) start_date = determine_sync_start_date(sophtron_account) Rails.logger.info "SophtronItem::Importer - Fetching transactions for account #{sophtron_account.account_id} from #{start_date}" begin - # Fetch transactions - transactions_data = sophtron_provider.get_account_transactions( - sophtron_account.customer_id, - sophtron_account.account_id, - start_date: start_date - ) - - # Extract data from Provider::Response object if needed - if transactions_data.respond_to?(:data) - transactions_data = transactions_data.data + if refresh && !initial_transaction_fetch?(sophtron_account) + refresh_result = refresh_account_before_transaction_fetch(sophtron_account) + return refresh_result if refresh_result.present? end + # Fetch transactions + transactions_data = Provider::Sophtron.response_data!( + sophtron_provider.get_account_transactions( + sophtron_account.account_id, + start_date: start_date + ) + ) + # Validate response structure unless transactions_data.is_a?(Hash) Rails.logger.error "SophtronItem::Importer - Invalid transactions_data format for account #{sophtron_account.account_id}" return { success: false, transactions_count: 0, error: "Invalid response format" } end - transactions_count = transactions_data[:transactions]&.count || 0 + transactions = transactions_data[:transactions] + unless transactions.is_a?(Array) + Rails.logger.error "SophtronItem::Importer - Missing transactions array for account #{sophtron_account.account_id}" + return { success: false, transactions_count: 0, error: "Missing transactions array" } + end + + transactions_count = transactions.count Rails.logger.info "SophtronItem::Importer - Fetched #{transactions_count} transactions for account #{sophtron_account.account_id}" # Store transactions in the account - if transactions_data[:transactions].present? + if transactions.any? begin existing_transactions = sophtron_account.raw_transactions_payload.to_a @@ -283,7 +314,7 @@ class SophtronItem::Importer # Filter to ONLY truly new transactions (skip duplicates) # Transactions are immutable on the bank side, so we don't need to update them - new_transactions = transactions_data[:transactions].select do |tx| + new_transactions = transactions.select do |tx| next false unless tx.is_a?(Hash) tx_id = tx.with_indifferent_access[:id] @@ -291,10 +322,11 @@ class SophtronItem::Importer end if new_transactions.any? - Rails.logger.info "SophtronItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions_data[:transactions].count - new_transactions.count} duplicates skipped) for account #{sophtron_account.account_id}" + Rails.logger.info "SophtronItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions.count - new_transactions.count} duplicates skipped) for account #{sophtron_account.account_id}" sophtron_account.upsert_sophtron_transactions_snapshot!(existing_transactions + new_transactions) else - Rails.logger.info "SophtronItem::Importer - No new transactions to store (all #{transactions_data[:transactions].count} were duplicates) for account #{sophtron_account.account_id}" + Rails.logger.info "SophtronItem::Importer - No new transactions to store (all #{transactions.count} were duplicates) for account #{sophtron_account.account_id}" + sophtron_account.upsert_sophtron_transactions_snapshot!(existing_transactions) if sophtron_account.raw_transactions_payload.nil? end rescue => e Rails.logger.error "SophtronItem::Importer - Failed to store transactions for account #{sophtron_account.account_id}: #{e.message}" @@ -302,20 +334,15 @@ class SophtronItem::Importer end else Rails.logger.info "SophtronItem::Importer - No transactions to store for account #{sophtron_account.account_id}" - end - - # Fetch and update balance - begin - fetch_and_update_balance(sophtron_account) - rescue => e - # Log but don't fail transaction import if balance fetch fails - Rails.logger.warn "SophtronItem::Importer - Failed to update balance for account #{sophtron_account.account_id}: #{e.message}" + sophtron_account.upsert_sophtron_transactions_snapshot!([]) if sophtron_account.raw_transactions_payload.nil? end { success: true, transactions_count: transactions_count } - rescue Provider::Error => e + rescue Provider::Sophtron::Error => e + requires_update = e.error_type.in?([ :unauthorized, :access_forbidden ]) + sophtron_item.update!(status: :requires_update) if requires_update Rails.logger.error "SophtronItem::Importer - Sophtron API error for account #{sophtron_account.id}: #{e.message}" - { success: false, transactions_count: 0, error: e.message } + { success: false, transactions_count: 0, error: e.message, requires_update: requires_update } rescue JSON::ParserError => e Rails.logger.error "SophtronItem::Importer - Failed to parse transaction response for account #{sophtron_account.id}: #{e.message}" { success: false, transactions_count: 0, error: "Failed to parse response" } @@ -326,93 +353,80 @@ class SophtronItem::Importer end end - def fetch_and_update_balance(sophtron_account) - begin - balance_data = sophtron_provider.get_account_balance(sophtron_account.customer_id, sophtron_account.account_id) - # Extract data from Provider::Response object if needed - if balance_data.respond_to?(:data) - balance_data = balance_data.data - end + def refresh_account_before_transaction_fetch(sophtron_account) + refresh_response = Provider::Sophtron.response_data!(sophtron_provider.refresh_account(sophtron_account.account_id)) + job_id = refresh_response.with_indifferent_access[:JobID] || refresh_response.with_indifferent_access[:job_id] + return nil if job_id.blank? - # Validate response structure - unless balance_data.is_a?(Hash) - Rails.logger.error "SophtronItem::Importer - Invalid balance_data format for account #{sophtron_account.account_id}" - return - end + job = Provider::Sophtron.response_data!(sophtron_provider.get_job_information(job_id)) + sophtron_item.upsert_job_snapshot!(job) - if balance_data[:balance].present? - balance_info = balance_data[:balance] - - # Validate balance info structure - unless balance_info.is_a?(Hash) - Rails.logger.error "SophtronItem::Importer - Invalid balance info format for account #{sophtron_account.account_id}" - return - end - - # Only update if we have a valid amount - if balance_info[:amount].present? - sophtron_account.update!( - balance: balance_info[:amount], - currency: balance_info[:currency].presence || sophtron_account.currency - ) - else - Rails.logger.warn "SophtronItem::Importer - No amount in balance data for account #{sophtron_account.account_id}" - end - else - Rails.logger.warn "SophtronItem::Importer - No balance data returned for account #{sophtron_account.account_id}" - end - rescue Provider::Error => e - Rails.logger.error "SophtronItem::Importer - Sophtron API error fetching balance for account #{sophtron_account.id}: #{e.message}" - # Don't fail if balance fetch fails - rescue ActiveRecord::RecordInvalid => e - Rails.logger.error "SophtronItem::Importer - Failed to save balance for account #{sophtron_account.id}: #{e.message}" - # Don't fail if balance save fails - rescue => e - Rails.logger.error "SophtronItem::Importer - Unexpected error updating balance for account #{sophtron_account.id}: #{e.class} - #{e.message}" - # Don't fail if balance update fails + if Provider::Sophtron.job_requires_input?(job) + sophtron_item.update!( + status: :requires_update, + current_job_id: job_id, + last_connection_error: "Sophtron refresh requires MFA" + ) + return { success: false, transactions_count: 0, error: "Sophtron refresh requires MFA", requires_update: true } end + + if Provider::Sophtron.job_failed?(job) + return { success: false, transactions_count: 0, error: "Sophtron refresh failed" } + end + + unless Provider::Sophtron.job_success?(job) || Provider::Sophtron.job_completed?(job) + SophtronRefreshPollJob.set(wait: SophtronRefreshPollJob::POLL_INTERVAL).perform_later( + sophtron_account, + job_id: job_id, + sync: sync + ) + + return { success: true, transactions_count: 0, refresh_pending: true } + end + + nil + rescue Provider::Sophtron::Error => e + requires_update = e.error_type.in?([ :unauthorized, :access_forbidden ]) + sophtron_item.update!(status: :requires_update) if requires_update + Rails.logger.error "SophtronItem::Importer - Sophtron API error refreshing account #{sophtron_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: e.message, requires_update: requires_update } end # Determines the appropriate start date for fetching transactions. # # Logic: - # - For accounts with stored transactions: uses last sync date minus 60-day buffer - # - For new accounts: uses account creation date minus 60 days, capped at 120 days ago + # - For accounts with stored transactions: uses last sync date minus a buffer + # - For new accounts: uses the sync window or provider default initial lookback # - # This ensures we capture any late-arriving transactions while limiting - # the historical window for new accounts. + # This captures late-arriving transactions while keeping history bounded. # # @param sophtron_account [SophtronAccount] The account to determine start date for # @return [Date] The start date for transaction sync def determine_sync_start_date(sophtron_account) - configured_start = sophtron_item.sync_start_date&.to_time - max_history_start = 3.years.ago - floor_start = [ configured_start, max_history_start ].compact.max - # Check if this account has any stored transactions - # If not, treat it as a first sync for this account even if the item has been synced before - has_stored_transactions = sophtron_account.raw_transactions_payload.to_a.any? + configured_start = sync&.window_start_date || sophtron_item.sync_start_date&.to_date + max_history_start = SophtronItem::MAX_TRANSACTION_HISTORY_YEARS.years.ago.to_date + floor_start = configured_start ? [ configured_start, max_history_start ].max : nil - if has_stored_transactions + if !initial_transaction_fetch?(sophtron_account) # Account has been synced before, use item-level logic with buffer # For subsequent syncs, fetch from last sync date with a buffer if sophtron_item.last_synced_at - [ sophtron_item.last_synced_at - 60.days, floor_start ].compact.max + [ sophtron_item.last_synced_at.to_date - INCREMENTAL_SYNC_BUFFER_DAYS, floor_start ].compact.max else # Fallback if item hasn't been synced but account has transactions - floor_start || 120.days.ago + floor_start || sophtron_item.initial_load_window_start_date end else # Account has no stored transactions - this is a first sync for this account - # Use account creation date or a generous historical window - account_baseline = sophtron_account.created_at || Time.current - first_sync_window = [ account_baseline - 60.days, floor_start || 120.days.ago ].max - - # Use the more recent of: (account created - 60 days) or (120 days ago) - # This caps old accounts at 120 days while respecting recent account creation dates - first_sync_window + # Use the configured sync window if present, otherwise the provider's default initial lookback. + floor_start || sophtron_item.initial_load_window_start_date end end + def initial_transaction_fetch?(sophtron_account) + sophtron_account.raw_transactions_payload.nil? && sophtron_item.last_synced_at.blank? + end + # Handles API errors and marks the item for re-authentication if needed. # # Authentication-related errors cause the item status to be set to @@ -420,7 +434,7 @@ class SophtronItem::Importer # # @param error_message [String] The error message from the API # @return [void] - # @raise [Provider::Error] Always raises an error with the message + # @raise [Provider::Sophtron::Error] Always raises an error with the message def handle_error(error_message) # Mark item as requiring update for authentication-related errors error_msg_lower = error_message.to_s.downcase @@ -438,7 +452,7 @@ class SophtronItem::Importer end Rails.logger.error "SophtronItem::Importer - API error: #{error_message}" - raise Provider::Error.new( + raise Provider::Sophtron::Error.new( "Sophtron API error: #{error_message}", :api_error ) diff --git a/app/models/sophtron_item/syncer.rb b/app/models/sophtron_item/syncer.rb index 70465ac83..3f74f5b54 100644 --- a/app/models/sophtron_item/syncer.rb +++ b/app/models/sophtron_item/syncer.rb @@ -36,7 +36,8 @@ class SophtronItem::Syncer def perform_sync(sync) # Phase 1: Import data from Sophtron API sync.update!(status_text: t("sophtron_items.syncer.importing_accounts")) if sync.respond_to?(:status_text) - sophtron_item.import_latest_sophtron_data + import_result = sophtron_item.import_latest_sophtron_data(sync: sync) + import_errors = import_errors_for(import_result) # Phase 2: Check account setup status and collect sync statistics sync.update!(status_text: t("sophtron_items.syncer.checking_account_configuration")) if sync.respond_to?(:status_text) @@ -79,9 +80,14 @@ class SophtronItem::Syncer end # Mark sync health - collect_health_stats(sync, errors: nil) + if import_errors.present? + collect_health_stats(sync, errors: import_errors) + raise StandardError.new(import_errors.map { |error| error[:message] }.join(", ")) + else + collect_health_stats(sync, errors: nil) + end rescue => e - collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) + collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) unless sync_errors_recorded?(sync) raise end @@ -93,4 +99,37 @@ class SophtronItem::Syncer def perform_post_sync # no-op end + + private + + def import_errors_for(import_result) + return [] if import_result.blank? || import_result[:success] + + if import_result[:error].present? + return [ { message: import_result[:error], category: "sync_error" } ] + end + + errors = [] + if import_result[:accounts_failed].to_i.positive? + errors << { + message: "#{import_result[:accounts_failed]} #{'account'.pluralize(import_result[:accounts_failed])} failed to import", + category: "account_import" + } + end + + if import_result[:transactions_failed].to_i.positive? + errors << { + message: "#{import_result[:transactions_failed]} #{'account'.pluralize(import_result[:transactions_failed])} failed to import transactions", + category: "transaction_import" + } + end + + errors.presence || [ { message: "Sophtron import failed", category: "sync_error" } ] + end + + def sync_errors_recorded?(sync) + return false unless sync.respond_to?(:sync_stats) + + sync.sync_stats.to_h["total_errors"].to_i.positive? + end end diff --git a/app/views/sophtron_items/_api_error.html.erb b/app/views/sophtron_items/_api_error.html.erb index 852fd09b7..e62f9628f 100644 --- a/app/views/sophtron_items/_api_error.html.erb +++ b/app/views/sophtron_items/_api_error.html.erb @@ -1,34 +1,40 @@ -<%# locals: (error_message:, return_path: nil) %> +<%# locals: (error_message:, return_path: nil, heading: nil, issue_keys: nil, action_label: nil) %> +<% heading ||= t("sophtron_items.api_error.unable_to_connect") %> +<% issue_keys ||= %w[incorrect_user_id invalid_access_key expired_credentials network_issue service_down] %> +<% action_path = return_path.presence || settings_providers_path %> +<% action_label ||= t("sophtron_items.api_error.check_provider_settings") %> + <%= turbo_frame_tag "modal" do %> <%= render DS::Dialog.new do |dialog| %> <% dialog.with_header(title: t("sophtron_items.api_error.title")) %> <% dialog.with_body do %>
-
- <%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %> -
-

<%= t("sophtron_items.api_error.unable_to_connect") %>

-

<%= h(error_message) %>

+
+
+ <%= icon("alert-circle", color: "destructive", size: "sm", class: "mt-0.5") %> +
+

<%= heading %>

+

<%= h(error_message) %>

+
-
+

<%= t("sophtron_items.api_error.common_issues_title") %>

-
    -
  • <%= t("sophtron_items.api_error.incorrect_user_id") %>
  • -
  • <%= t("sophtron_items.api_error.invalid_access_key") %>
  • -
  • <%= t("sophtron_items.api_error.expired_credentials") %>
  • -
  • <%= t("sophtron_items.api_error.network_issue") %>
  • -
  • <%= t("sophtron_items.api_error.service_down") %>
  • +
      + <% issue_keys.each do |issue_key| %> +
    • <%= t("sophtron_items.api_error.#{issue_key}") %>
    • + <% end %>
-
- <%= link_to (return_path.presence || settings_providers_path), - class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors", - data: { turbo: false } do %> - <%= t("sophtron_items.api_error.check_provider_settings") %> - <% end %> +
+ <%= render DS::Link.new( + text: action_label, + href: action_path, + variant: :primary, + data: { turbo: false } + ) %>
<% end %> diff --git a/app/views/sophtron_items/_setup_required.html.erb b/app/views/sophtron_items/_setup_required.html.erb index 9c759597f..d306640f4 100644 --- a/app/views/sophtron_items/_setup_required.html.erb +++ b/app/views/sophtron_items/_setup_required.html.erb @@ -3,17 +3,19 @@ <% dialog.with_header(title: t("sophtron_items.sophtron_setup_required.title")) %> <% dialog.with_body do %>
-
- <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> -
-

<%= t("sophtron_items.sophtron_setup_required.heading") %>

-

<%= t("sophtron_items.sophtron_setup_required.description") %>

+
+
+ <%= icon("alert-circle", color: "warning", size: "sm", class: "mt-0.5") %> +
+

<%= t("sophtron_items.sophtron_setup_required.heading") %>

+

<%= t("sophtron_items.sophtron_setup_required.description") %>

+
-
+

<%= t("sophtron_items.sophtron_setup_required.setup_steps_title") %>

-
    +
    1. <%= t("sophtron_items.sophtron_setup_required.step_1_html") %>
    2. <%= t("sophtron_items.sophtron_setup_required.step_2_html") %>
    3. <%= t("sophtron_items.sophtron_setup_required.step_3_html") %>
    4. @@ -21,14 +23,15 @@
-
- <%= link_to settings_providers_path, - class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors", - data: { turbo: false } do %> - <%= t("sophtron_items.sophtron_setup_required.go_to_provider_settings") %> - <% end %> +
+ <%= render DS::Link.new( + text: t("sophtron_items.sophtron_setup_required.go_to_provider_settings"), + href: settings_providers_path, + variant: :primary, + data: { turbo: false } + ) %>
<% end %> <% end %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/sophtron_items/_sophtron_item.html.erb b/app/views/sophtron_items/_sophtron_item.html.erb index dd4431af6..f360de15a 100644 --- a/app/views/sophtron_items/_sophtron_item.html.erb +++ b/app/views/sophtron_items/_sophtron_item.html.erb @@ -1,6 +1,7 @@ <%# locals: (sophtron_item:) %> <%= tag.div id: dom_id(sophtron_item) do %> + <% provider_display_name = sophtron_item.provider_display_name %>
@@ -11,14 +12,14 @@ <%= image_tag sophtron_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> <% else %>
- <%= tag.p sophtron_item.name&.first&.upcase || "?", class: "text-orange-600 text-xs font-medium" %> + <%= tag.p provider_display_name.first.upcase, class: "text-orange-600 text-xs font-medium" %>
<% end %>
- <%= tag.p sophtron_item.name, class: "font-medium text-primary" %> + <%= tag.p provider_display_name, class: "font-medium text-primary" %> <% if sophtron_item.scheduled_for_deletion? %>

<%= t(".deletion_in_progress") %>

<% end %> @@ -70,7 +71,7 @@ icon: "trash-2", href: sophtron_item_path(sophtron_item), method: :delete, - confirm: CustomConfirm.for_resource_deletion(sophtron_item.name, high_severity: true) + confirm: CustomConfirm.for_resource_deletion(provider_display_name, high_severity: true) ) %> <% end %>
diff --git a/app/views/sophtron_items/connect.html.erb b/app/views/sophtron_items/connect.html.erb new file mode 100644 index 000000000..210f63a5a --- /dev/null +++ b/app/views/sophtron_items/connect.html.erb @@ -0,0 +1,82 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+ <%= form_with url: select_accounts_sophtron_items_path, method: :get, class: "space-y-3" do %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :account_id, @account&.id %> + <%= hidden_field_tag :return_to, @return_to %> + <%= hidden_field_tag :connect_new_institution, true if @connect_new_institution %> + +
+
+ <%= label_tag :institution_name, t(".institution_search_label"), class: "form-field__label" %> + <%= text_field_tag :institution_name, @institution_search, placeholder: t(".institution_search_placeholder"), autocomplete: "off", class: "form-field__input" %> +
+
+ +
+ <%= render DS::Button.new(text: t(".search"), type: "submit") %> +
+ <% end %> + + <% if @institution_search.present? && @institution_search.length < 2 %> +

<%= t(".search_too_short") %>

+ <% elsif @institution_search.present? && @institutions.empty? %> +

<%= t(".no_institutions") %>

+ <% end %> + +
+ <% @institutions.each_with_index do |institution, index| %> + <% institution = institution.with_indifferent_access %> + <% institution_id = institution[:InstitutionID] || institution[:institution_id] %> + <% field_suffix = (institution_id.presence || index).to_s.parameterize %> + <% bank_username_id = "bank_username_#{field_suffix}" %> + <% bank_password_id = "bank_password_#{field_suffix}" %> + <%= form_with url: connect_institution_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3 rounded-lg border border-primary bg-container-inset p-3", data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :institution_id, institution_id %> + <%= hidden_field_tag :institution_name, institution[:InstitutionName] || institution[:institution_name] %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :account_id, @account&.id %> + <%= hidden_field_tag :return_to, @return_to %> + <%= hidden_field_tag :connect_new_institution, true if @connect_new_institution %> + +

<%= institution[:InstitutionName] || institution[:institution_name] %>

+ +
+
+
+ <%= label_tag bank_username_id, t(".username"), class: "form-field__label" %> + <%= text_field_tag :bank_username, nil, id: bank_username_id, autocomplete: "username", class: "form-field__input" %> +
+
+ +
+
+ <%= label_tag bank_password_id, t(".password"), class: "form-field__label" %> + <%= password_field_tag :bank_password, nil, id: bank_password_id, autocomplete: "current-password", class: "form-field__input" %> +
+
+
+ +
+ <%= render DS::Button.new(text: t(".connect"), type: "submit") %> +
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t(".cancel"), + href: @return_to || accounts_path, + variant: :secondary, + data: { turbo_frame: "_top", action: "click->DS--dialog#close" } + ) %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/connection_status.html.erb b/app/views/sophtron_items/connection_status.html.erb new file mode 100644 index 000000000..6c7ffa368 --- /dev/null +++ b/app/views/sophtron_items/connection_status.html.erb @@ -0,0 +1,51 @@ +<% polling_data = if @timed_out + {} + else + { + controller: "polling", + polling_frame_id_value: "modal", + polling_url_value: connection_status_sophtron_item_path(@sophtron_item, accountable_type: @accountable_type, account_id: @account_id, return_to: @return_to, poll_attempt: @next_poll_attempt, post_mfa: @post_mfa_polling), + polling_interval_value: @poll_interval_ms + } + end %> +<% check_again_attempt = @timed_out ? 1 : (@next_poll_attempt || @poll_attempt.to_i + 1) %> + +<%= turbo_frame_tag "modal" do %> + <%= tag.div(data: polling_data) do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+
+
+ <% if @timed_out %> + <%= icon "alert-circle", size: "sm", class: "mt-0.5" %> + <% else %> + <%= icon "loader", size: "sm", class: "mt-0.5 animate-spin" %> + <% end %> + +
+

+ <%= @timed_out ? t(".timeout") : t(".waiting") %> +

+

+ <%= t(".attempt", attempt: (@poll_attempt || 1), max: @max_poll_attempts) %> +

+
+
+
+ +
+ <%= render DS::Link.new( + text: t(".check_again"), + href: connection_status_sophtron_item_path(@sophtron_item, accountable_type: @accountable_type, account_id: @account_id, return_to: @return_to, poll_attempt: check_again_attempt, post_mfa: @post_mfa_polling), + variant: :primary, + data: { turbo_frame: "modal", turbo_prefetch: false } + ) %> +
+
+ <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/mfa.html.erb b/app/views/sophtron_items/mfa.html.erb new file mode 100644 index 000000000..6100c6075 --- /dev/null +++ b/app/views/sophtron_items/mfa.html.erb @@ -0,0 +1,97 @@ +<%= turbo_frame_tag "modal" do %> + <% security_questions = Array(@challenge[:security_questions]) %> + <% token_methods = Array(@challenge[:token_methods]) %> + <% safe_captcha_image = @challenge[:captcha_image].to_s.split(%r{[^A-Za-z0-9+/=\s]}, 2).first.to_s.gsub(/\s+/, "") %> + + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+ <% if security_questions.any? %> + <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3", data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :mfa_type, "security_answer" %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :account_id, @account_id %> + <%= hidden_field_tag :return_to, @return_to %> + + <% security_questions.each_with_index do |question, index| %> + <% answer_field_id = "security_answer_#{index}" %> +
+
+ <%= label_tag answer_field_id, question, class: "form-field__label" %> + <%= text_field_tag "security_answers[]", nil, id: answer_field_id, autocomplete: "off", class: "form-field__input" %> +
+
+ <% end %> + +
+ <%= render DS::Button.new(text: t(".submit"), type: "submit") %> +
+ <% end %> + <% elsif token_methods.any? %> +
+ <% token_methods.each do |method| %> + <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :mfa_type, "token_choice" %> + <%= hidden_field_tag :token_choice, method %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :account_id, @account_id %> + <%= hidden_field_tag :return_to, @return_to %> + <%= button_tag type: "submit", class: "w-full rounded-lg border border-primary bg-container-inset p-3 text-left text-sm text-primary transition-colors hover:bg-container-inset-hover" do %> + <%= method %> + <% end %> + <% end %> + <% end %> +
+ <% elsif @challenge[:token_sent] %> + <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3", data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :mfa_type, "token_input" %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :account_id, @account_id %> + <%= hidden_field_tag :return_to, @return_to %> +
+
+ <%= label_tag :token_input, t(".token"), class: "form-field__label" %> + <%= text_field_tag :token_input, nil, autocomplete: "one-time-code", class: "form-field__input" %> +
+
+
+ <%= render DS::Button.new(text: t(".submit"), type: "submit") %> +
+ <% end %> + <% elsif @challenge[:token_read].present? %> +
+

<%= @challenge[:token_read] %>

+ <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :mfa_type, "verify_phone" %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :account_id, @account_id %> + <%= hidden_field_tag :return_to, @return_to %> + <%= render DS::Button.new(text: t(".phone_confirmed"), type: "submit") %> + <% end %> +
+ <% elsif safe_captcha_image.present? %> + <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3", data: { turbo_frame: "modal" } do %> + <%= hidden_field_tag :mfa_type, "captcha" %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :account_id, @account_id %> + <%= hidden_field_tag :return_to, @return_to %> +
+ <%= image_tag "data:image/png;base64,#{safe_captcha_image}", alt: t(".captcha_alt"), class: "max-w-full rounded-md" %> +
+
+
+ <%= label_tag :captcha_input, t(".captcha"), class: "form-field__label" %> + <%= text_field_tag :captcha_input, nil, autocomplete: "off", class: "form-field__input" %> +
+
+
+ <%= render DS::Button.new(text: t(".submit"), type: "submit") %> +
+ <% end %> + <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/select_accounts.html.erb b/app/views/sophtron_items/select_accounts.html.erb index 6063ac4e6..760847e2e 100644 --- a/app/views/sophtron_items/select_accounts.html.erb +++ b/app/views/sophtron_items/select_accounts.html.erb @@ -17,10 +17,10 @@ <% @available_accounts.each do |account| %> <% Rails.logger.debug "Sophtron account data: #{account.inspect}" %> <% has_blank_name = account[:account_name].blank? %> -