[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
This commit is contained in:
Juan José Mata
2026-05-09 21:55:20 +02:00
committed by GitHub
parent acb82790d5
commit c92b984cef
19 changed files with 989 additions and 41 deletions

View File

@@ -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?

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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? %>

View File

@@ -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 %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
@@ -20,6 +22,9 @@
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<%= tag.p provider_display_name, class: "font-medium text-primary" %>
<% if manual_sync_required %>
<span class="rounded-full bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning"><%= t(".manual_sync") %></span>
<% end %>
<% if sophtron_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
@@ -56,15 +61,37 @@
</div>
<div class="flex items-center gap-2">
<% 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"),

View File

@@ -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 @@
<div class="flex gap-2 justify-end">
<%= 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 }
) %>

View File

@@ -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 %>
<div class="space-y-4">
<div class="rounded-lg border border-primary bg-container-inset p-3">
<div class="flex items-start gap-3">
<%= icon "check-circle", size: "sm", color: "success", class: "mt-0.5" %>
<div class="space-y-1 text-sm">
<p class="font-medium text-primary"><%= t(".message") %></p>
<p class="text-xs text-secondary"><%= t(".description") %></p>
</div>
</div>
</div>
<div class="flex justify-end">
<%= render DS::Link.new(text: t(".close"), href: accounts_path, variant: :primary, data: { turbo_frame: "_top" }) %>
</div>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -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 %>
<span class="font-medium"><%= method %></span>
<% 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" %>
<div class="form-field">
<div class="form-field__body">
<%= label_tag :token_input, t(".token"), class: "form-field__label" %>
@@ -65,18 +59,14 @@
<p class="rounded-lg border border-primary bg-container-inset p-3 text-sm text-primary"><%= @challenge[:token_read] %></p>
<%= 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 %>
</div>
<% 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" %>
<div class="rounded-lg border border-primary bg-container-inset p-3">
<%= image_tag "data:image/png;base64,#{safe_captcha_image}", alt: t(".captcha_alt"), class: "max-w-full rounded-md" %>
</div>

View File

@@ -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 <a href="%{accounts_path}" class="link">Accounts</a> 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"

View File

@@ -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 <a href=\"%{accounts_path}\" class=\"link\">Számlák</a> 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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

6
db/schema.rb generated
View File

@@ -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"

View File

@@ -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)

View File

@@ -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!(

View File

@@ -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