mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 15:15:01 +00:00
[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:
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
6
app/views/sophtron_items/_mfa_context_fields.html.erb
Normal file
6
app/views/sophtron_items/_mfa_context_fields.html.erb
Normal 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? %>
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 }
|
||||
) %>
|
||||
|
||||
23
app/views/sophtron_items/manual_sync_complete.html.erb
Normal file
23
app/views/sophtron_items/manual_sync_complete.html.erb
Normal 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 %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
6
db/schema.rb
generated
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user