From c92b984cef118182543f86a21e4b5c53b75ca3c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 9 May 2026 21:55:20 +0200 Subject: [PATCH] [codex] Add Sophtron manual sync fixes (#1714) * Add manual Sophtron sync flow (#1705) Branch-to-branch merge. * Copy edits * Make Sophtron manual sync institution scoped * Populate Sophtron manual sync stats * Restore Sophtron bank credential copy * Address Sophtron manual sync review feedback * Scope manual sync processing failure handling * Hide raw Sophtron processor errors from flash * Clear Sophtron manual sync pointers on provider errors * Keep manual Sophtron MFA on manual sync records * Preserve manual sync processing error details --- app/controllers/sophtron_items_controller.rb | 350 +++++++++++++++++- app/models/sophtron_account.rb | 16 + app/models/sophtron_item.rb | 59 ++- app/models/sophtron_item/importer.rb | 2 +- app/models/sophtron_item/syncer.rb | 8 +- .../_mfa_context_fields.html.erb | 6 + .../sophtron_items/_sophtron_item.html.erb | 37 +- .../sophtron_items/connection_status.html.erb | 4 +- .../manual_sync_complete.html.erb | 23 ++ app/views/sophtron_items/mfa.html.erb | 20 +- config/locales/views/sophtron_items/en.yml | 22 +- config/locales/views/sophtron_items/hu.yml | 20 + config/routes.rb | 1 + ...20000_add_manual_sync_to_sophtron_items.rb | 7 + ...00_add_manual_sync_to_sophtron_accounts.rb | 17 + db/schema.rb | 6 +- .../sophtron_items_controller_test.rb | 345 +++++++++++++++++ test/models/sophtron_item/importer_test.rb | 34 ++ test/models/sophtron_item_test.rb | 53 +++ 19 files changed, 989 insertions(+), 41 deletions(-) create mode 100644 app/views/sophtron_items/_mfa_context_fields.html.erb create mode 100644 app/views/sophtron_items/manual_sync_complete.html.erb create mode 100644 db/migrate/20260508120000_add_manual_sync_to_sophtron_items.rb create mode 100644 db/migrate/20260508130000_add_manual_sync_to_sophtron_accounts.rb diff --git a/app/controllers/sophtron_items_controller.rb b/app/controllers/sophtron_items_controller.rb index 81a86cd60..6222954ef 100644 --- a/app/controllers/sophtron_items_controller.rb +++ b/app/controllers/sophtron_items_controller.rb @@ -1,20 +1,23 @@ class SophtronItemsController < ApplicationController + include SyncStats::Collector + CONNECTION_STATUS_MAX_POLLS = 6 LOGIN_PROGRESS_CONNECTION_STATUS_MAX_POLLS = 15 POST_MFA_CONNECTION_STATUS_MAX_POLLS = 15 CONNECTION_STATUS_POLL_INTERVAL_MS = 4_000 MAX_SECURITY_ANSWERS = 10 MAX_SECURITY_ANSWER_LENGTH = 256 + MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY = "manual_sync_processed_sophtron_account_ids" before_action :set_sophtron_item, only: [ :show, :edit, :update, :destroy, :connect_institution, :sync, - :connection_status, :submit_mfa, + :connection_status, :submit_mfa, :toggle_manual_sync, :setup_accounts, :complete_account_setup ] before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :connect_institution, - :edit, :update, :destroy, :sync, :connection_status, :submit_mfa, + :edit, :update, :destroy, :sync, :connection_status, :submit_mfa, :toggle_manual_sync, :setup_accounts, :complete_account_setup ] @@ -146,6 +149,11 @@ class SophtronItemsController < ApplicationController @sophtron_item.upsert_job_snapshot!(job) if Provider::Sophtron.job_success?(job) + if manual_sync_flow? + complete_manual_sync_from_job(job) + return + end + @sophtron_item.update!( current_job_id: nil, last_connection_error: nil, @@ -157,18 +165,27 @@ class SophtronItemsController < ApplicationController @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) + elsif Provider::Sophtron.job_completed?(job) + if manual_sync_flow? + complete_manual_sync_from_job(job) + return + end + + if post_mfa_polling? + return if render_account_selection_if_accounts_available(@sophtron_item) + end render_pending_connection_status elsif Provider::Sophtron.job_failed?(job) failure_message = sophtron_connection_failure_message(job) @sophtron_item.update!( current_job_id: nil, - user_institution_id: nil, + current_job_sophtron_account_id: nil, + user_institution_id: (manual_sync_flow? ? @sophtron_item.user_institution_id : nil), last_connection_error: failure_message, status: :requires_update ) + fail_manual_sync!(manual_sync_record, failure_message) if manual_sync_flow? render_institution_connection_error(failure_message) else render_pending_connection_status @@ -415,6 +432,11 @@ class SophtronItemsController < ApplicationController end def sync + if @sophtron_item.manual_sync_required? + start_manual_sync + return + end + @sophtron_item.sync_later unless @sophtron_item.syncing? respond_to do |format| @@ -423,6 +445,42 @@ class SophtronItemsController < ApplicationController end end + def toggle_manual_sync + toggle_accounts = manual_sync_toggle_sophtron_accounts + + if toggle_accounts.exists? + enabled = if @sophtron_item.manual_sync? + @sophtron_item.sophtron_accounts.where.not(id: toggle_accounts.select(:id)).update_all(manual_sync: true, updated_at: Time.current) + false + else + !toggle_accounts.requires_manual_sync.exists? + end + toggle_accounts.update_all(manual_sync: enabled, updated_at: Time.current) + @sophtron_item.update!(manual_sync: false) unless enabled + elsif params[:institution_key].present? || params[:user_institution_id].present? + redirect_back_or_to accounts_path, alert: t("sophtron_items.sync.no_linked_accounts") + return + else + @sophtron_item.update!(manual_sync: !@sophtron_item.manual_sync?) + enabled = @sophtron_item.manual_sync? + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path, notice: t(".success_#{enabled ? 'enabled' : 'disabled'}") } + format.turbo_stream do + flash.now[:notice] = t(".success_#{enabled ? 'enabled' : 'disabled'}") + render turbo_stream: [ + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(@sophtron_item), + partial: "sophtron_items/sophtron_item", + locals: { sophtron_item: @sophtron_item.reload } + ), + *flash_notification_stream_items + ] + end + end + end + def setup_accounts @api_error = fetch_sophtron_accounts_from_api @@ -580,6 +638,277 @@ class SophtronItemsController < ApplicationController private + def start_manual_sync + if @sophtron_item.current_job_sophtron_account_id.present? + redirect_to active_manual_sync_path, alert: t(".already_running") + return + end + + unless linked_manual_sync_sophtron_accounts.exists? + redirect_back_or_to accounts_path, alert: t(".no_linked_accounts") + return + end + + sync = @sophtron_item.syncs.create! + sync.start! if sync.may_start? + @manual_sync = sync + + provider = @sophtron_item.sophtron_provider + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + reset_manual_sync_progress!(sync) + start_next_manual_sync_account(sync, provider) + rescue Provider::Sophtron::Error => e + clear_or_fail_manual_sync_after_error!(sync, e.message) if defined?(sync) && sync.present? + Rails.logger.error("Sophtron manual sync error: #{e.message}") + redirect_back_or_to accounts_path, alert: t(".api_error", message: e.message) + end + + def active_manual_sync_path + return accounts_path if @sophtron_item.current_job_id.blank? + + connection_status_sophtron_item_path( + @sophtron_item, + connection_context_params.merge( + manual_sync: true, + sync_id: manual_sync_record&.id, + sophtron_account_id: @sophtron_item.current_job_sophtron_account_id + ) + ) + end + + def start_next_manual_sync_account(sync, provider) + sophtron_account = next_manual_sync_sophtron_account(sync) + + unless sophtron_account + @sophtron_item.update!( + current_job_id: nil, + current_job_sophtron_account_id: nil, + last_connection_error: nil, + status: :good + ) + sync.finalize_if_all_children_finalized + flash.discard(:alert) + @manual_sync = sync + render :manual_sync_complete, layout: false + return + end + + start_manual_sync_for_account(sophtron_account, provider, sync) + end + + def start_manual_sync_for_account(sophtron_account, provider, sync) + refresh_response = sophtron_response_data!(provider.refresh_account(sophtron_account.account_id)).with_indifferent_access + job_id = refresh_response[:JobID] || refresh_response[:job_id] + + if job_id.blank? + complete_manual_sync!(sophtron_account, provider, sync) + start_next_manual_sync_account(sync, provider) + return + end + + @sophtron_item.update!( + current_job_id: job_id, + current_job_sophtron_account_id: sophtron_account.id, + raw_job_payload: refresh_response, + job_status: nil, + last_connection_error: nil, + status: :good + ) + + job = sophtron_response_data!(provider.get_job_information(job_id)) + @sophtron_item.upsert_job_snapshot!(job) + + if Provider::Sophtron.job_requires_input?(job) + @challenge = @sophtron_item.build_mfa_challenge(job) + prepare_connection_status_context + render :mfa, layout: false + elsif Provider::Sophtron.job_failed?(job) + failure_message = t(".failed") + fail_manual_sync_and_clear_job!(sync, failure_message) + redirect_back_or_to accounts_path, alert: failure_message + elsif Provider::Sophtron.job_success?(job) || Provider::Sophtron.job_completed?(job) + complete_manual_sync!(sophtron_account, provider, sync) + start_next_manual_sync_account(sync, provider) + else + @poll_attempt = 1 + render_pending_connection_status + end + end + + def complete_manual_sync_from_job(job) + sophtron_account = @sophtron_item.current_job_sophtron_account + sophtron_account ||= linked_manual_sync_sophtron_accounts.find_by(id: params[:sophtron_account_id]) if params[:sophtron_account_id].present? + sync = manual_sync_record + + unless sophtron_account && sync + @sophtron_item.update!(current_job_id: nil, current_job_sophtron_account_id: nil) + render_api_error(t("sophtron_items.sync.no_linked_accounts"), accounts_path) + return + end + + provider = @sophtron_item.sophtron_provider + complete_manual_sync!(sophtron_account, provider, sync) + start_next_manual_sync_account(sync, provider) + rescue Provider::Sophtron::Error => e + fail_manual_sync_and_clear_job!(sync, e.message) if defined?(sync) && sync.present? + render_api_error(t("sophtron_items.sync.api_error", message: e.message), accounts_path) + end + + def complete_manual_sync!(sophtron_account, provider, sync) + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + result = SophtronItem::Importer.new(@sophtron_item, sophtron_provider: provider, sync: sync) + .import_transactions_after_refresh(sophtron_account) + + unless result[:success] + error_message = result[:error] || t("sophtron_items.sync.failed") + fail_manual_sync_and_clear_job!(sync, error_message) + raise Provider::Sophtron::Error.new(error_message, :api_error) + end + + processing_result = process_manual_sync_account!(sync, sophtron_account) + mark_manual_sync_account_processed!(sync, sophtron_account) + collect_manual_sync_stats!(sync, processing_result) + @sophtron_item.update!( + current_job_id: nil, + current_job_sophtron_account_id: nil, + last_connection_error: nil, + status: :good + ) + + if (account = sophtron_account.current_account) + account.sync_later( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + else + sync.finalize_if_all_children_finalized + end + + @manual_sync_account = sophtron_account + @manual_sync = sync + end + + def process_manual_sync_account!(sync, sophtron_account) + SophtronAccount::Processor.new(sophtron_account.reload).process + rescue StandardError => e + Rails.logger.error("Sophtron manual sync processing error: #{e.class} - #{e.message}") + fail_manual_sync_and_clear_job!(sync, e.message) + raise Provider::Sophtron::Error.new(t("sophtron_items.sync.processing_failed"), :api_error) + end + + def fail_manual_sync_and_clear_job!(sync, message) + clear_manual_sync_job!(message, status: :requires_update) + fail_manual_sync!(sync, message) + end + + def clear_or_fail_manual_sync_after_error!(sync, message) + if sync.failed? + clear_manual_sync_job!(@sophtron_item.last_connection_error, status: :requires_update) + else + fail_manual_sync_and_clear_job!(sync, message) + end + end + + def clear_manual_sync_job!(message = nil, status: nil) + attributes = { + current_job_id: nil, + current_job_sophtron_account_id: nil + } + attributes[:last_connection_error] = message if message.present? + attributes[:status] = status if status.present? + + @sophtron_item.update!(attributes) + end + + def fail_manual_sync!(sync, message) + return unless sync + + sync.start! if sync.may_start? + sync.fail! if sync.may_fail? + sync.update!(error: message) + end + + def manual_sync_record + return @manual_sync if defined?(@manual_sync) && @manual_sync.present? + + sync = @sophtron_item.syncs.find_by(id: params[:sync_id]) if params[:sync_id].present? + sync || visible_manual_sync_record + end + + def visible_manual_sync_record + @sophtron_item.syncs.visible.ordered.detect do |sync| + sync.sync_stats.to_h.key?(MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY) + end + end + + def linked_manual_sync_sophtron_accounts + @sophtron_item.manual_sync_sophtron_accounts + end + + def manual_sync_toggle_sophtron_accounts + accounts = @sophtron_item.sophtron_accounts.order(:created_at, :id) + institution_key = params[:institution_key].presence || params[:user_institution_id] + return accounts if institution_key.blank? + + account_ids = accounts.select do |sophtron_account| + sophtron_account.institution_key.to_s == institution_key.to_s + end.map(&:id) + + accounts.where(id: account_ids) + end + + def next_manual_sync_sophtron_account(sync) + processed_ids = manual_sync_processed_sophtron_account_ids(sync) + linked_manual_sync_sophtron_accounts.detect { |sophtron_account| processed_ids.exclude?(sophtron_account.id.to_s) } + end + + def reset_manual_sync_progress!(sync) + sync.update!(sync_stats: { MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => [] }) + end + + def mark_manual_sync_account_processed!(sync, sophtron_account) + processed_ids = manual_sync_processed_sophtron_account_ids(sync) + processed_ids << sophtron_account.id.to_s + stats = sync.sync_stats.to_h + sync.update!(sync_stats: stats.merge(MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => processed_ids.uniq)) + end + + def collect_manual_sync_stats!(sync, processing_result) + mark_import_started(sync) + collect_setup_stats(sync, provider_accounts: @sophtron_item.sophtron_accounts.includes(:account_provider, :account)) + + account_ids = @sophtron_item.sophtron_accounts + .where(id: manual_sync_processed_sophtron_account_ids(sync)) + .includes(:account_provider) + .filter_map { |sophtron_account| sophtron_account.current_account&.id } + + collect_transaction_stats( + sync, + account_ids: account_ids, + source: "sophtron", + window_start: sync.syncing_at || sync.created_at, + window_end: Time.current + ) + + collect_manual_sync_health_stats!(sync, processing_result) + end + + def collect_manual_sync_health_stats!(sync, processing_result) + if processing_result.is_a?(Hash) && processing_result[:success] == false + errors = Array(processing_result[:errors]).presence || [ { message: t("sophtron_items.sync.failed"), category: "transaction_import" } ] + collect_health_stats(sync, errors: errors) + elsif sync.sync_stats.to_h["total_errors"].to_i.zero? + collect_health_stats(sync, errors: nil) + end + end + + def manual_sync_processed_sophtron_account_ids(sync) + Array(sync.sync_stats.to_h[MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY]).map(&:to_s) + end + def configured_sophtron_item Current.family.configured_sophtron_item end @@ -746,6 +1075,9 @@ class SophtronItemsController < ApplicationController @accountable_type = params[:accountable_type] || "Depository" @account_id = params[:account_id] @return_to = safe_return_to_path + @manual_sync_flow = manual_sync_flow? + @manual_sync_id = manual_sync_record&.id if @manual_sync_flow + @manual_sync_sophtron_account_id = params[:sophtron_account_id] || @sophtron_item.current_job_sophtron_account_id @poll_interval_ms = CONNECTION_STATUS_POLL_INTERVAL_MS @post_mfa_polling = post_mfa_polling? @max_poll_attempts = connection_status_max_polls @@ -782,6 +1114,10 @@ class SophtronItemsController < ApplicationController ActiveModel::Type::Boolean.new.cast(params[:post_mfa]) || post_mfa_job_payload?(@sophtron_item.raw_job_payload) end + def manual_sync_flow? + ActiveModel::Type::Boolean.new.cast(params[:manual_sync]) || @sophtron_item.current_job_sophtron_account_id.present? + end + def post_mfa_job_payload?(job_payload) job = (job_payload || {}).with_indifferent_access job[:TokenInput].present? || %w[TokenInput TransactionTable].include?(job[:LastStep].to_s) @@ -812,7 +1148,7 @@ class SophtronItemsController < ApplicationController 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], + issue_keys: %w[bad_credentials verification_code institution_timeout unsupported_mfa], action_label: t("sophtron_items.api_error.try_again") ) end @@ -888,7 +1224,7 @@ class SophtronItemsController < ApplicationController end def connection_context_params - params.permit(:accountable_type, :account_id, :return_to, :post_mfa, :connect_new_institution).to_h.compact + params.permit(:accountable_type, :account_id, :return_to, :post_mfa, :connect_new_institution, :manual_sync, :sync_id, :sophtron_account_id, :institution_key, :user_institution_id).to_h.compact end def connect_new_institution_flow? diff --git a/app/models/sophtron_account.rb b/app/models/sophtron_account.rb index d6f111b3a..e22769883 100644 --- a/app/models/sophtron_account.rb +++ b/app/models/sophtron_account.rb @@ -26,6 +26,9 @@ class SophtronAccount < ApplicationRecord has_one :account, through: :account_provider, source: :account has_one :linked_account, through: :account_provider, source: :account + scope :requires_manual_sync, -> { where(manual_sync: true) } + scope :automatic_sync, -> { where(manual_sync: false) } + validates :name, :currency, presence: true validate :has_balance # Returns the linked Maybe Account for this Sophtron account. @@ -35,6 +38,18 @@ class SophtronAccount < ApplicationRecord account end + def institution_name + institution_metadata.to_h["name"].presence || sophtron_item&.institution_name + end + + def institution_user_institution_id + institution_metadata.to_h["user_institution_id"].presence || sophtron_item&.user_institution_id + end + + def institution_key + institution_user_institution_id.presence || institution_name + end + # Updates this SophtronAccount with fresh data from the Sophtron API. # # Maps Sophtron field names to our database schema and saves the changes. @@ -78,6 +93,7 @@ class SophtronAccount < ApplicationRecord customer_id: first_present(snapshot, :customer_id, :CustomerID) || customer_id, member_id: first_present(snapshot, :member_id, :MemberID) || member_id ) + self.manual_sync = true if new_record? && sophtron_item&.manual_sync? save! end diff --git a/app/models/sophtron_item.rb b/app/models/sophtron_item.rb index dc278fe65..aa6194681 100644 --- a/app/models/sophtron_item.rb +++ b/app/models/sophtron_item.rb @@ -43,6 +43,7 @@ class SophtronItem < ApplicationRecord validates :access_key, presence: true, on: :create belongs_to :family + belongs_to :current_job_sophtron_account, class_name: "SophtronAccount", optional: true has_one_attached :logo has_many :sophtron_accounts, dependent: :destroy @@ -83,12 +84,57 @@ class SophtronItem < ApplicationRecord raise end - def process_accounts - return [] if sophtron_accounts.empty? + def linked_visible_sophtron_accounts + sophtron_accounts.joins(:account).merge(Account.visible) + end + + def automatic_sync_sophtron_accounts + return linked_visible_sophtron_accounts.none if manual_sync? + + linked_visible_sophtron_accounts.automatic_sync + end + + def manual_sync_required? + manual_sync? || sophtron_accounts.requires_manual_sync.exists? + end + + def manual_sync_sophtron_accounts + linked_accounts = sophtron_accounts.joins(:account_provider).order(:created_at, :id) + manual_accounts = linked_accounts.requires_manual_sync + + return manual_accounts if manual_accounts.exists? + + manual_sync? ? linked_accounts : linked_accounts.none + end + + def connected_institution_options + sophtron_accounts.order(:created_at, :id).filter_map do |sophtron_account| + institution_key = sophtron_account.institution_key + next if institution_key.blank? + + { + institution_key: institution_key, + name: sophtron_account.institution_name.presence || institution_display_name + } + end.uniq { |institution| institution[:institution_key].to_s } + end + + def manual_sync_required_for_institution?(institution_key) + institution_accounts = sophtron_accounts.select do |sophtron_account| + sophtron_account.institution_key.to_s == institution_key.to_s + end + + return manual_sync? if institution_accounts.empty? + + institution_accounts.any?(&:manual_sync?) || (manual_sync? && !sophtron_accounts.requires_manual_sync.exists?) + end + + def process_accounts(sophtron_accounts_scope: linked_visible_sophtron_accounts) + return [] if sophtron_accounts_scope.empty? results = [] # Only process accounts that are linked and have active status - sophtron_accounts.joins(:account).merge(Account.visible).each do |sophtron_account| + sophtron_accounts_scope.each do |sophtron_account| begin result = SophtronAccount::Processor.new(sophtron_account).process results << { sophtron_account_id: sophtron_account.id, success: true, result: result } @@ -102,12 +148,13 @@ class SophtronItem < ApplicationRecord results end - def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) - return [] if accounts.empty? + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil, sophtron_accounts_scope: linked_visible_sophtron_accounts) + linked_accounts = sophtron_accounts_scope.includes(:account_provider).filter_map(&:current_account) + return [] if linked_accounts.empty? results = [] # Only schedule syncs for active accounts - accounts.visible.each do |account| + linked_accounts.each do |account| begin account.sync_later( parent_sync: parent_sync, diff --git a/app/models/sophtron_item/importer.rb b/app/models/sophtron_item/importer.rb index 7a58308e6..403024cf6 100644 --- a/app/models/sophtron_item/importer.rb +++ b/app/models/sophtron_item/importer.rb @@ -139,7 +139,7 @@ class SophtronItem::Importer transactions_imported = 0 transactions_failed = 0 - linked_accounts = sophtron_item.sophtron_accounts.joins(:account).merge(Account.visible) + linked_accounts = sophtron_item.automatic_sync_sophtron_accounts linked_accounts.each do |sophtron_account| begin result = fetch_and_store_transactions(sophtron_account) diff --git a/app/models/sophtron_item/syncer.rb b/app/models/sophtron_item/syncer.rb index 3f74f5b54..2c21be53f 100644 --- a/app/models/sophtron_item/syncer.rb +++ b/app/models/sophtron_item/syncer.rb @@ -44,7 +44,7 @@ class SophtronItem::Syncer collect_setup_stats(sync, provider_accounts: sophtron_item.sophtron_accounts) # Check for unlinked accounts - linked_accounts = sophtron_item.sophtron_accounts.joins(:account_provider) + linked_accounts = sophtron_item.automatic_sync_sophtron_accounts unlinked_accounts = sophtron_item.sophtron_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) # Set pending_account_setup if there are unlinked accounts @@ -61,7 +61,7 @@ class SophtronItem::Syncer sync.update!(status_text: t("sophtron_items.syncer.processing_transactions")) if sync.respond_to?(:status_text) mark_import_started(sync) Rails.logger.info "SophtronItem::Syncer - Processing #{linked_accounts.count} linked accounts" - sophtron_item.process_accounts + sophtron_item.process_accounts(sophtron_accounts_scope: linked_accounts) Rails.logger.info "SophtronItem::Syncer - Finished processing accounts" # Phase 4: Schedule balance calculations for linked accounts @@ -69,13 +69,15 @@ class SophtronItem::Syncer sophtron_item.schedule_account_syncs( parent_sync: sync, window_start_date: sync.window_start_date, - window_end_date: sync.window_end_date + window_end_date: sync.window_end_date, + sophtron_accounts_scope: linked_accounts ) # Phase 5: Collect transaction statistics account_ids = linked_accounts.includes(:account_provider).filter_map { |la| la.current_account&.id } collect_transaction_stats(sync, account_ids: account_ids, source: "sophtron") else + sync.update!(status_text: t("sophtron_items.syncer.manual_sync_required")) if sophtron_item.manual_sync_required? && sync.respond_to?(:status_text) Rails.logger.info "SophtronItem::Syncer - No linked accounts to process" end diff --git a/app/views/sophtron_items/_mfa_context_fields.html.erb b/app/views/sophtron_items/_mfa_context_fields.html.erb new file mode 100644 index 000000000..84dab88f7 --- /dev/null +++ b/app/views/sophtron_items/_mfa_context_fields.html.erb @@ -0,0 +1,6 @@ +<%= hidden_field_tag :accountable_type, @accountable_type %> +<%= hidden_field_tag :account_id, @account_id %> +<%= hidden_field_tag :return_to, @return_to %> +<%= hidden_field_tag :manual_sync, @manual_sync_flow if @manual_sync_flow.present? %> +<%= hidden_field_tag :sync_id, @manual_sync_id if @manual_sync_id.present? %> +<%= hidden_field_tag :sophtron_account_id, @manual_sync_sophtron_account_id if @manual_sync_sophtron_account_id.present? %> diff --git a/app/views/sophtron_items/_sophtron_item.html.erb b/app/views/sophtron_items/_sophtron_item.html.erb index f360de15a..a8c71d900 100644 --- a/app/views/sophtron_items/_sophtron_item.html.erb +++ b/app/views/sophtron_items/_sophtron_item.html.erb @@ -2,6 +2,8 @@ <%= tag.div id: dom_id(sophtron_item) do %> <% provider_display_name = sophtron_item.provider_display_name %> + <% manual_sync_required = sophtron_item.manual_sync_required? %> + <% connected_institution_options = sophtron_item.connected_institution_options %>
@@ -20,6 +22,9 @@
<%= tag.p provider_display_name, class: "font-medium text-primary" %> + <% if manual_sync_required %> + <%= t(".manual_sync") %> + <% end %> <% if sophtron_item.scheduled_for_deletion? %>

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

<% end %> @@ -56,15 +61,37 @@
- <% if Rails.env.development? %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_sophtron_item_path(sophtron_item) + <% if manual_sync_required || Rails.env.development? %> + <%= render DS::Button.new( + variant: :icon, + icon: "refresh-cw", + href: sync_sophtron_item_path(sophtron_item), + frame: (manual_sync_required ? "modal" : nil), + title: t(".sync_now") ) %> <% end %> <%= render DS::Menu.new do |menu| %> + <% if connected_institution_options.many? %> + <% connected_institution_options.each do |institution| %> + <% institution_manual_sync_required = sophtron_item.manual_sync_required_for_institution?(institution[:institution_key]) %> + <% menu.with_item( + variant: "button", + text: t(institution_manual_sync_required ? ".automatic_sync_for" : ".manual_sync_action_for", institution: institution[:name]), + icon: institution_manual_sync_required ? "refresh-cw" : "pause-circle", + href: toggle_manual_sync_sophtron_item_path(sophtron_item, institution_key: institution[:institution_key]), + method: :post + ) %> + <% end %> + <% else %> + <% menu.with_item( + variant: "button", + text: t(manual_sync_required ? ".automatic_sync" : ".manual_sync_action"), + icon: manual_sync_required ? "refresh-cw" : "pause-circle", + href: toggle_manual_sync_sophtron_item_path(sophtron_item), + method: :post + ) %> + <% end %> <% menu.with_item( variant: "button", text: t(".delete"), diff --git a/app/views/sophtron_items/connection_status.html.erb b/app/views/sophtron_items/connection_status.html.erb index 6c7ffa368..5a2e8b5b6 100644 --- a/app/views/sophtron_items/connection_status.html.erb +++ b/app/views/sophtron_items/connection_status.html.erb @@ -4,7 +4,7 @@ { 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_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, manual_sync: @manual_sync_flow, sync_id: @manual_sync_id, sophtron_account_id: @manual_sync_sophtron_account_id), polling_interval_value: @poll_interval_ms } end %> @@ -39,7 +39,7 @@
<%= 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), + 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, manual_sync: @manual_sync_flow, sync_id: @manual_sync_id, sophtron_account_id: @manual_sync_sophtron_account_id), variant: :primary, data: { turbo_frame: "modal", turbo_prefetch: false } ) %> diff --git a/app/views/sophtron_items/manual_sync_complete.html.erb b/app/views/sophtron_items/manual_sync_complete.html.erb new file mode 100644 index 000000000..70b0f3e57 --- /dev/null +++ b/app/views/sophtron_items/manual_sync_complete.html.erb @@ -0,0 +1,23 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+
+
+ <%= icon "check-circle", size: "sm", color: "success", class: "mt-0.5" %> +
+

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

+

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

+
+
+
+ +
+ <%= render DS::Link.new(text: t(".close"), href: accounts_path, variant: :primary, data: { turbo_frame: "_top" }) %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/mfa.html.erb b/app/views/sophtron_items/mfa.html.erb index 6100c6075..2247c7c29 100644 --- a/app/views/sophtron_items/mfa.html.erb +++ b/app/views/sophtron_items/mfa.html.erb @@ -11,9 +11,7 @@ <% 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 %> + <%= render "sophtron_items/mfa_context_fields" %> <% security_questions.each_with_index do |question, index| %> <% answer_field_id = "security_answer_#{index}" %> @@ -35,9 +33,7 @@ <%= 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 %> + <%= render "sophtron_items/mfa_context_fields" %> <%= 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 %> @@ -47,9 +43,7 @@ <% 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 %> + <%= render "sophtron_items/mfa_context_fields" %>
<%= label_tag :token_input, t(".token"), class: "form-field__label" %> @@ -65,18 +59,14 @@

<%= @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 "sophtron_items/mfa_context_fields" %> <%= 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 %> + <%= render "sophtron_items/mfa_context_fields" %>
<%= image_tag "data:image/png;base64,#{safe_captcha_image}", alt: t(".captcha_alt"), class: "max-w-full rounded-md" %>
diff --git a/config/locales/views/sophtron_items/en.yml b/config/locales/views/sophtron_items/en.yml index 1a03116d6..a74c76e77 100644 --- a/config/locales/views/sophtron_items/en.yml +++ b/config/locales/views/sophtron_items/en.yml @@ -97,17 +97,23 @@ en: unknown_challenge: Unknown Sophtron verification step. sophtron_item: accounts_need_setup: Accounts need setup + automatic_sync: Use automatic sync delete: Delete connection deletion_in_progress: deletion in progress... error: Error no_accounts_description: This connection has no linked accounts yet. no_accounts_title: No accounts + manual_sync: Manual sync + manual_sync_action: Require manual sync + manual_sync_action_for: "Require manual sync for %{institution}" + automatic_sync_for: "Use automatic sync for %{institution}" setup_action: Set Up New Accounts setup_description: "%{linked} of %{total} accounts linked. Choose account types for your newly imported Sophtron accounts." setup_needed: New accounts ready to set up status: "Synced %{timestamp} ago" status_never: Never synced status_with_summary: "Last synced %{timestamp} ago • %{summary}" + sync_now: Sync now syncing: Syncing... total: Total unlinked: Unlinked @@ -203,7 +209,20 @@ en: no_accounts: "No accounts to set up." success: "Successfully created %{count} account(s)." sync: + already_running: Sophtron manual sync is already in progress. + api_error: "Sophtron manual sync failed: %{message}" + failed: Sophtron manual sync failed + no_linked_accounts: This Sophtron institution does not have any linked accounts to sync. + processing_failed: Sophtron manual sync could not process the refreshed transactions. success: Sync started + toggle_manual_sync: + success_disabled: Sophtron institution will sync automatically. + success_enabled: Sophtron institution now requires manual sync. + manual_sync_complete: + close: Close + description: Account balances will finish updating in the background. + message: Transactions were downloaded after Sophtron verification. + title: Sophtron Sync Started sophtron_setup_required: title: Sophtron Setup Required message: > @@ -226,7 +245,7 @@ en: expired_credentials: "Expired Credentials: Generate a new User ID and Access Key from Sophtron" network_issue: "Network Issue: Check your internet connection" service_down: "Service Down: Sophtron API may be temporarily unavailable" - bank_credentials: "Bank credentials: Check the username and password for the selected institution" + bad_credentials: "Bank credentials: Check the username and password are correct" verification_code: "Verification code: Make sure the latest code was entered before it expired" institution_timeout: "Institution timeout: The bank login page did not finish in time" unsupported_mfa: "MFA support: Sophtron may not support this institution's current verification flow" @@ -264,6 +283,7 @@ en: configured_html: 'Configured and ready to use. Visit the Accounts tab to manage and set up accounts.' not_configured: "Not configured" syncer: + manual_sync_required: "Manual Sophtron sync is required for this institution; skipping those accounts during automated sync." importing_accounts: "Importing accounts from Sophtron..." checking_account_configuration: "Checking account configuration..." accounts_need_setup: "%{count} account(s) need setup" diff --git a/config/locales/views/sophtron_items/hu.yml b/config/locales/views/sophtron_items/hu.yml index d412ab818..7589414df 100644 --- a/config/locales/views/sophtron_items/hu.yml +++ b/config/locales/views/sophtron_items/hu.yml @@ -61,17 +61,23 @@ hu: no_user_id: A Sophtron felhasználói azonosító nincs beállítva. Kérlek állítsd be a Beállításokban. sophtron_item: accounts_need_setup: Számlák beállítást igényelnek + automatic_sync: Automatikus szinkronizálás használata delete: Kapcsolat törlése deletion_in_progress: törlés folyamatban... error: Hiba no_accounts_description: Ehhez a kapcsolathoz még nincsenek összekapcsolt számlák. no_accounts_title: Nincsenek számlák + manual_sync: Kézi szinkronizálás + manual_sync_action: Kézi szinkronizálás megkövetelése + manual_sync_action_for: "Kézi szinkronizálás megkövetelése ehhez: %{institution}" + automatic_sync_for: "Automatikus szinkronizálás használata ehhez: %{institution}" setup_action: Új számlák beállítása setup_description: "%{linked} / %{total} számla összekapcsolva. Válaszd ki az újonnan importált Sophtron számlák típusait." setup_needed: Új számlák beállításra várnak status: "Szinkronizálva %{timestamp} ezelőtt" status_never: Még nem szinkronizált status_with_summary: "Utolsó szinkronizálás %{timestamp} ezelőtt • %{summary}" + sync_now: Szinkronizálás most syncing: Szinkronizálás... total: Összesen unlinked: Nincs összekapcsolva @@ -163,7 +169,20 @@ hu: no_accounts: "Nincs beállítandó számla." success: "%{count} számla sikeresen létrehozva." sync: + already_running: A Sophtron kézi szinkronizálása már folyamatban van. + api_error: "A Sophtron kézi szinkronizálása sikertelen: %{message}" + failed: A Sophtron kézi szinkronizálása sikertelen + no_linked_accounts: Ehhez a Sophtron intézményhez nincs szinkronizálható összekapcsolt számla. + processing_failed: A Sophtron kézi szinkronizálása nem tudta feldolgozni a frissített tranzakciókat. success: Szinkronizálás elindítva + toggle_manual_sync: + success_disabled: A Sophtron intézmény automatikusan fog szinkronizálni. + success_enabled: A Sophtron intézmény mostantól kézi szinkronizálást igényel. + manual_sync_complete: + close: Bezárás + description: A számlaegyenlegek frissítése a háttérben fejeződik be. + message: A tranzakciók letöltése elindult a Sophtron ellenőrzés után. + title: Sophtron szinkronizálás elindítva sophtron_setup_required: title: Sophtron beállítás szükséges message: > @@ -218,6 +237,7 @@ hu: configured_html: "Beállítva és használatra kész. A számlák kezeléséhez és beállításához látogass el a Számlák lapra." not_configured: "Nincs beállítva" syncer: + manual_sync_required: "A kézi Sophtron szinkronizálás engedélyezve van; automatikus szinkronizálás kihagyva." importing_accounts: "Számlák importálása a Sophtron-ból..." checking_account_configuration: "Számlakonfiguráció ellenőrzése..." accounts_need_setup: "%{count} számla beállítást igényel" diff --git a/config/routes.rb b/config/routes.rb index fcda7daf5..b143c64d5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -548,6 +548,7 @@ Rails.application.routes.draw do member do post :connect_institution post :sync + post :toggle_manual_sync post :balances get :connection_status post :submit_mfa diff --git a/db/migrate/20260508120000_add_manual_sync_to_sophtron_items.rb b/db/migrate/20260508120000_add_manual_sync_to_sophtron_items.rb new file mode 100644 index 000000000..ce94f6460 --- /dev/null +++ b/db/migrate/20260508120000_add_manual_sync_to_sophtron_items.rb @@ -0,0 +1,7 @@ +class AddManualSyncToSophtronItems < ActiveRecord::Migration[7.2] + def change + add_column :sophtron_items, :manual_sync, :boolean, null: false, default: false + add_column :sophtron_items, :current_job_sophtron_account_id, :uuid + add_index :sophtron_items, :current_job_sophtron_account_id + end +end diff --git a/db/migrate/20260508130000_add_manual_sync_to_sophtron_accounts.rb b/db/migrate/20260508130000_add_manual_sync_to_sophtron_accounts.rb new file mode 100644 index 000000000..e36357cc0 --- /dev/null +++ b/db/migrate/20260508130000_add_manual_sync_to_sophtron_accounts.rb @@ -0,0 +1,17 @@ +class AddManualSyncToSophtronAccounts < ActiveRecord::Migration[7.2] + def change + add_column :sophtron_accounts, :manual_sync, :boolean, default: false, null: false + + reversible do |dir| + dir.up do + execute <<~SQL.squish + UPDATE sophtron_accounts + SET manual_sync = TRUE + FROM sophtron_items + WHERE sophtron_accounts.sophtron_item_id = sophtron_items.id + AND sophtron_items.manual_sync = TRUE + SQL + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 6512c65ff..641c5e640 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_07_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_05_08_130000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1405,6 +1405,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_07_120000) do t.string "account_number_mask" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "manual_sync", default: false, null: false t.index ["account_id"], name: "index_sophtron_accounts_on_account_id" t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id" t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true @@ -1437,6 +1438,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_07_120000) do t.text "last_connection_error" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "manual_sync", default: false, null: false + t.uuid "current_job_sophtron_account_id" + t.index ["current_job_sophtron_account_id"], name: "index_sophtron_items_on_current_job_sophtron_account_id" t.index ["customer_id"], name: "index_sophtron_items_on_customer_id" t.index ["family_id"], name: "index_sophtron_items_on_family_id" t.index ["status"], name: "index_sophtron_items_on_status" diff --git a/test/controllers/sophtron_items_controller_test.rb b/test/controllers/sophtron_items_controller_test.rb index c69f5fa09..0ae7132a1 100644 --- a/test/controllers/sophtron_items_controller_test.rb +++ b/test/controllers/sophtron_items_controller_test.rb @@ -569,6 +569,351 @@ class SophtronItemsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to connection_status_sophtron_item_path(@item, post_mfa: true) end + test "toggle_manual_sync marks linked Sophtron institution accounts manual" do + sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Sophtron Checking", + currency: "USD", + balance: 100 + ) + AccountProvider.create!(account: accounts(:depository), provider: sophtron_account) + + assert_not @item.manual_sync? + assert_not sophtron_account.manual_sync? + + post toggle_manual_sync_sophtron_item_url(@item) + + assert_redirected_to accounts_path + assert_not @item.reload.manual_sync? + assert sophtron_account.reload.manual_sync? + assert_includes SophtronItem.syncable, @item + assert_equal "Sophtron institution now requires manual sync.", flash[:notice] + end + + test "toggle_manual_sync can target one Sophtron institution on a mixed item" do + first_sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Apple Card", + currency: "USD", + balance: 100, + institution_metadata: { name: "Apple", user_institution_id: "ui-apple" } + ) + second_sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-2", + name: "Amazon Card", + currency: "USD", + balance: 200, + institution_metadata: { name: "Amazon", user_institution_id: "ui-amazon" } + ) + AccountProvider.create!(account: accounts(:depository), provider: first_sophtron_account) + AccountProvider.create!(account: accounts(:credit_card), provider: second_sophtron_account) + + post toggle_manual_sync_sophtron_item_url(@item), params: { user_institution_id: "ui-amazon" } + + assert_not first_sophtron_account.reload.manual_sync? + assert second_sophtron_account.reload.manual_sync? + end + + test "toggle_manual_sync makes targeted institution automatic when whole item is manual" do + first_sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Apple Card", + currency: "USD", + balance: 100, + institution_metadata: { name: "Apple", user_institution_id: "ui-apple" } + ) + second_sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-2", + name: "Amazon Card", + currency: "USD", + balance: 200, + institution_metadata: { name: "Amazon", user_institution_id: "ui-amazon" } + ) + AccountProvider.create!(account: accounts(:depository), provider: first_sophtron_account) + AccountProvider.create!(account: accounts(:credit_card), provider: second_sophtron_account) + @item.update!(manual_sync: true) + + post toggle_manual_sync_sophtron_item_url(@item), params: { user_institution_id: "ui-amazon" } + + assert_not @item.reload.manual_sync? + assert first_sophtron_account.reload.manual_sync? + assert_not second_sophtron_account.reload.manual_sync? + assert_equal "Sophtron institution will sync automatically.", flash[:notice] + end + + test "manual sync starts Sophtron refresh and renders MFA challenge" do + @item.update!(user_institution_id: "ui-1") + sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Sophtron Checking", + currency: "USD", + balance: 100, + manual_sync: true + ) + AccountProvider.create!(account: accounts(:depository), provider: sophtron_account) + + provider = mock + provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" }) + provider.expects(:get_job_information).with("job-1").returns({ + SecurityQuestion: [ "What is your favorite color?" ].to_json, + SuccessFlag: nil, + LastStatus: "Waiting" + }) + SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider) + + assert_no_enqueued_jobs only: SyncJob do + assert_difference -> { @item.syncs.count }, 1 do + post sync_sophtron_item_url(@item) + end + end + + assert_response :success + assert_includes response.body, "What is your favorite color?" + assert_equal "job-1", @item.reload.current_job_id + assert_equal sophtron_account.id, @item.current_job_sophtron_account_id + assert @item.syncs.ordered.first.syncing? + end + + test "manual sync creates its own sync when an automatic sync is visible" do + @item.update!(user_institution_id: "ui-1") + automatic_sync = @item.syncs.create! + automatic_sync.start! + sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Sophtron Checking", + currency: "USD", + balance: 100, + manual_sync: true + ) + AccountProvider.create!(account: accounts(:depository), provider: sophtron_account) + + provider = mock + provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" }) + provider.expects(:get_job_information).with("job-1").returns({ + SecurityQuestion: [ "What is your favorite color?" ].to_json, + LastStatus: "Waiting" + }) + SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider) + + assert_difference -> { @item.syncs.count }, 1 do + post sync_sophtron_item_url(@item) + end + + assert_response :success + assert_equal automatic_sync.id, @item.syncs.ordered.second.id + manual_sync = @item.syncs.ordered.first + assert_equal [], manual_sync.sync_stats["manual_sync_processed_sophtron_account_ids"] + assert_includes response.body, "value=\"#{manual_sync.id}\"" + assert_not_includes response.body, "value=\"#{automatic_sync.id}\"" + end + + test "manual sync does not start another refresh while one is active" do + @item.update!(user_institution_id: "ui-1") + sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Sophtron Checking", + currency: "USD", + balance: 100, + manual_sync: true + ) + AccountProvider.create!(account: accounts(:depository), provider: sophtron_account) + sync = @item.syncs.create!(sync_stats: { SophtronItemsController::MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => [] }) + sync.start! + @item.update!(current_job_id: "job-1", current_job_sophtron_account_id: sophtron_account.id) + SophtronItem.any_instance.expects(:sophtron_provider).never + + post sync_sophtron_item_url(@item) + + assert_redirected_to connection_status_sophtron_item_path( + @item, + manual_sync: true, + sync_id: sync.id, + sophtron_account_id: sophtron_account.id + ) + assert_equal "Sophtron manual sync is already in progress.", flash[:alert] + end + + test "manual sync refreshes every linked Sophtron account" do + @item.update!(user_institution_id: "ui-1") + first_sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Sophtron Checking", + currency: "USD", + balance: 100, + manual_sync: true + ) + second_sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-2", + name: "Sophtron Card", + currency: "USD", + balance: 200, + manual_sync: true + ) + AccountProvider.create!(account: accounts(:depository), provider: first_sophtron_account) + AccountProvider.create!(account: accounts(:credit_card), provider: second_sophtron_account) + + provider = mock + sequence = sequence("sophtron manual refresh") + provider.expects(:refresh_account).with("acct-1").in_sequence(sequence).returns({ JobID: "job-1" }) + provider.expects(:get_job_information).with("job-1").in_sequence(sequence).returns({ LastStatus: "Completed" }) + provider.expects(:refresh_account).with("acct-2").in_sequence(sequence).returns({ JobID: "job-2" }) + provider.expects(:get_job_information).with("job-2").in_sequence(sequence).returns({ LastStatus: "Completed" }) + SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider) + SophtronItem::Importer.any_instance.expects(:import_transactions_after_refresh) + .with(first_sophtron_account) + .returns({ success: true, transactions_count: 1 }) + SophtronItem::Importer.any_instance.expects(:import_transactions_after_refresh) + .with(second_sophtron_account) + .returns({ success: true, transactions_count: 1 }) + SophtronAccount::Processor.any_instance.expects(:process).twice.returns({ transactions_imported: 1 }) + + assert_enqueued_jobs 2, only: SyncJob do + post sync_sophtron_item_url(@item) + end + + assert_response :success + assert_includes response.body, "Transactions were downloaded after Sophtron verification." + @item.reload + assert_nil @item.current_job_id + assert_nil @item.current_job_sophtron_account_id + assert_equal( + [ first_sophtron_account.id, second_sophtron_account.id ].map(&:to_s), + @item.syncs.ordered.first.sync_stats["manual_sync_processed_sophtron_account_ids"] + ) + stats = @item.syncs.ordered.first.sync_stats + assert_equal 2, stats["total_accounts"] + assert_equal 2, stats["linked_accounts"] + assert_equal 0, stats["unlinked_accounts"] + assert_equal 0, stats["total_errors"] + assert stats.key?("tx_seen") + assert stats.key?("tx_imported") + assert stats.key?("tx_updated") + end + + test "manual sync clears job pointers when refresh job fails" do + @item.update!(user_institution_id: "ui-1") + sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Sophtron Checking", + currency: "USD", + balance: 100, + manual_sync: true + ) + AccountProvider.create!(account: accounts(:depository), provider: sophtron_account) + + provider = mock + provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" }) + provider.expects(:get_job_information).with("job-1").returns({ + SuccessFlag: false, + LastStatus: "Timeout" + }) + SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider) + + post sync_sophtron_item_url(@item) + + assert_redirected_to accounts_path + @item.reload + assert_nil @item.current_job_id + assert_nil @item.current_job_sophtron_account_id + assert_equal "requires_update", @item.status + assert_equal "Sophtron manual sync failed", @item.last_connection_error + assert @item.syncs.ordered.first.failed? + end + + test "manual sync clears job pointers when job polling raises provider error" do + @item.update!(user_institution_id: "ui-1") + sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Sophtron Checking", + currency: "USD", + balance: 100, + manual_sync: true + ) + AccountProvider.create!(account: accounts(:depository), provider: sophtron_account) + + provider = mock + provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" }) + provider.expects(:get_job_information) + .with("job-1") + .raises(Provider::Sophtron::Error.new("Sophtron unavailable", :api_error)) + SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider) + + post sync_sophtron_item_url(@item) + + assert_redirected_to accounts_path + @item.reload + assert_nil @item.current_job_id + assert_nil @item.current_job_sophtron_account_id + assert_equal "requires_update", @item.status + assert_equal "Sophtron unavailable", @item.last_connection_error + assert @item.syncs.ordered.first.failed? + end + + test "manual sync fails and clears job pointers when processing raises" do + @item.update!(user_institution_id: "ui-1") + sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Sophtron Checking", + currency: "USD", + balance: 100, + manual_sync: true + ) + AccountProvider.create!(account: accounts(:depository), provider: sophtron_account) + + provider = mock + provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" }) + provider.expects(:get_job_information).with("job-1").returns({ LastStatus: "Completed" }) + SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider) + SophtronItem::Importer.any_instance.expects(:import_transactions_after_refresh) + .with(sophtron_account) + .returns({ success: true, transactions_count: 1 }) + SophtronAccount::Processor.any_instance.expects(:process).raises(StandardError.new("processor failed")) + + post sync_sophtron_item_url(@item) + + assert_redirected_to accounts_path + @item.reload + assert_nil @item.current_job_id + assert_nil @item.current_job_sophtron_account_id + assert_equal "requires_update", @item.status + assert_equal "processor failed", @item.last_connection_error + assert @item.syncs.ordered.first.failed? + assert_equal "Sophtron manual sync failed: Sophtron manual sync could not process the refreshed transactions.", flash[:alert] + assert_not_includes flash[:alert], "processor failed" + end + + test "submit_mfa preserves manual sync context" do + @item.update!(user_institution_id: "ui-1", current_job_id: "job-1") + sync = @item.syncs.create! + sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Sophtron Checking", + currency: "USD", + balance: 100, + manual_sync: true + ) + provider = mock + provider.expects(:update_job_token_input).with("job-1", token_input: "123456").returns({}) + SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider) + + post submit_mfa_sophtron_item_url(@item), params: { + mfa_type: "token_input", + token_input: "123456", + manual_sync: true, + sync_id: sync.id, + sophtron_account_id: sophtron_account.id + } + + assert_redirected_to connection_status_sophtron_item_path( + @item, + manual_sync: "true", + post_mfa: true, + sophtron_account_id: sophtron_account.id, + sync_id: sync.id + ) + end + + test "link_existing_account links manual account to sophtron account" do @item.update!(user_institution_id: "ui-1") account = accounts(:depository) diff --git a/test/models/sophtron_item/importer_test.rb b/test/models/sophtron_item/importer_test.rb index 6fe106777..85449c343 100644 --- a/test/models/sophtron_item/importer_test.rb +++ b/test/models/sophtron_item/importer_test.rb @@ -104,6 +104,40 @@ class SophtronItem::ImporterTest < ActiveSupport::TestCase assert_equal 1, sophtron_account.reload.raw_transactions_payload.count end + test "automatic import skips linked accounts that require manual sync" do + account = accounts(:depository) + sophtron_account = @item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Checking", + currency: "USD", + balance: 100, + manual_sync: true + ) + AccountProvider.create!(account: account, provider: sophtron_account) + + provider = mock + provider.expects(:get_accounts).with("ui-1").returns({ + accounts: [ + { + account_id: "acct-1", + account_name: "Checking", + balance: "100.00", + balance_currency: "USD", + currency: "USD" + }.with_indifferent_access + ], + total: 1 + }) + provider.expects(:refresh_account).never + provider.expects(:get_account_transactions).never + + result = SophtronItem::Importer.new(@item, sophtron_provider: provider).import + + assert result[:success] + assert_equal 0, result[:transactions_imported] + assert_nil sophtron_account.reload.raw_transactions_payload + end + test "later sync refreshes account after an empty initial transaction fetch" do account = accounts(:depository) sophtron_account = @item.sophtron_accounts.create!( diff --git a/test/models/sophtron_item_test.rb b/test/models/sophtron_item_test.rb index 7be757e6d..c78285698 100644 --- a/test/models/sophtron_item_test.rb +++ b/test/models/sophtron_item_test.rb @@ -162,4 +162,57 @@ class SophtronItemTest < ActiveSupport::TestCase end end end + test "manual Sophtron accounts do not remove the whole item from automatic sync scope" do + manual_item = @family.sophtron_items.create!( + name: "Manual Sophtron", + user_id: "manual-user", + access_key: Base64.strict_encode64("secret-key") + ) + manual_account = manual_item.sophtron_accounts.create!( + account_id: "acct-manual", + name: "Manual Sophtron Checking", + currency: "USD", + balance: 100, + manual_sync: true + ) + auto_account = manual_item.sophtron_accounts.create!( + account_id: "acct-auto", + name: "Automatic Sophtron Checking", + currency: "USD", + balance: 100 + ) + AccountProvider.create!(account: accounts(:depository), provider: manual_account) + AccountProvider.create!(account: accounts(:credit_card), provider: auto_account) + + assert_includes SophtronItem.active, manual_item + assert_includes SophtronItem.syncable, manual_item + assert_equal [ auto_account ], manual_item.automatic_sync_sophtron_accounts.to_a + assert_equal [ manual_account ], manual_item.manual_sync_sophtron_accounts.to_a + end + + test "whole item manual mode removes linked accounts from automatic sync scope" do + manual_item = @family.sophtron_items.create!( + name: "Manual Sophtron", + user_id: "manual-user", + access_key: Base64.strict_encode64("secret-key"), + manual_sync: true + ) + first_account = manual_item.sophtron_accounts.create!( + account_id: "acct-1", + name: "Manual Sophtron Checking", + currency: "USD", + balance: 100 + ) + second_account = manual_item.sophtron_accounts.create!( + account_id: "acct-2", + name: "Manual Sophtron Card", + currency: "USD", + balance: 200 + ) + AccountProvider.create!(account: accounts(:depository), provider: first_account) + AccountProvider.create!(account: accounts(:credit_card), provider: second_account) + + assert_empty manual_item.automatic_sync_sophtron_accounts + assert_equal [ first_account, second_account ], manual_item.manual_sync_sophtron_accounts.to_a + end end