Files
sure/app/models/enable_banking_item/syncer.rb
Tobias Rahloff fe47c918bb feat(enable_banking): support MFA/decoupled banks and harden session handling (#2174)
Decoupled/MFA banks (e.g. VR Bank in Holstein) were hard-blocked because the
authorize flow aborted whenever auth_methods[0] was DECOUPLED. Enable Banking's
hosted /auth page actually coordinates decoupled SCA and redirects back with a
code, so route these banks through it instead:

- Provider#start_authorization accepts and forwards an auth_method param
- EnableBankingItem#select_auth_method picks the best method
  (REDIRECT > DECOUPLED > EMBEDDED), filtering by psu_type and skipping hidden
  methods
- Shared begin_authorization! re-fetches ASPSP metadata on each authorize and
  reauthorize, so the method is always re-derived (no persistence required)
- Remove the DECOUPLED block in the controller

Also stop the integration from constantly reporting "session expired":

- Only a session-level GET /sessions 401/404 flips the connection to
  requires_update; per-account 401/404 are retried and no longer kill the
  whole connection
- Reconcile session_expires_at from the API's access.valid_until on every sync
- Treat an expired session as a graceful requires_update state instead of
  raising a bare error

No schema changes. Adds covering tests.
2026-06-04 19:53:52 +02:00

92 lines
3.9 KiB
Ruby

class EnableBankingItem::Syncer
include SyncStats::Collector
attr_reader :enable_banking_item
def initialize(enable_banking_item)
@enable_banking_item = enable_banking_item
end
def perform_sync(sync)
# An expired/missing session is an expected state that needs user action, not a
# hard failure. Mark the connection requires_update and finish the sync
# gracefully so the UI surfaces the "Reconnect" CTA instead of a red sync error.
unless enable_banking_item.session_valid?
sync.update!(status_text: "Session expired - re-authorization required") if sync.respond_to?(:status_text)
enable_banking_item.update!(status: :requires_update)
collect_health_stats(sync, errors: nil)
return
end
# Phase 1: Import data from Enable Banking API
sync.update!(status_text: "Importing accounts from Enable Banking...") if sync.respond_to?(:status_text)
import_result = enable_banking_item.import_latest_enable_banking_data
unless import_result[:success]
# A session-level auth failure detected mid-import flips the item to
# requires_update — surface that as a graceful reconnect state, not a red
# error. Transient/per-account failures leave status good and fall through
# to a normal sync error that retries next time.
if enable_banking_item.requires_update?
sync.update!(status_text: "Re-authorization required") if sync.respond_to?(:status_text)
collect_health_stats(sync, errors: nil)
return
end
error_msg = import_result[:error]
if error_msg.blank? && (import_result[:accounts_failed].to_i > 0 || import_result[:transactions_failed].to_i > 0)
parts = []
parts << "#{import_result[:accounts_failed]} #{'account'.pluralize(import_result[:accounts_failed])} failed" if import_result[:accounts_failed].to_i > 0
parts << "#{import_result[:transactions_failed]} #{'transaction'.pluralize(import_result[:transactions_failed])} failed" if import_result[:transactions_failed].to_i > 0
error_msg = parts.join(", ")
end
raise StandardError.new(error_msg.presence || "Import failed")
end
# Phase 2: Check account setup status and collect sync statistics
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
collect_setup_stats(sync, provider_accounts: enable_banking_item.enable_banking_accounts.includes(:account_provider, :account))
unlinked_accounts = enable_banking_item.enable_banking_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
if unlinked_accounts.any?
enable_banking_item.update!(pending_account_setup: true)
sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text)
else
enable_banking_item.update!(pending_account_setup: false)
end
# Phase 3: Process transactions for linked and visible accounts only
linked_account_ids = enable_banking_item.enable_banking_accounts
.joins(:account_provider)
.joins(:account)
.merge(Account.visible)
.pluck("accounts.id")
if linked_account_ids.any?
sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text)
enable_banking_item.process_accounts
# Collect transaction statistics
collect_transaction_stats(sync, account_ids: linked_account_ids, source: "enable_banking")
# Phase 4: Schedule balance calculations for linked accounts
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
enable_banking_item.schedule_account_syncs(
parent_sync: sync,
window_start_date: sync.window_start_date,
window_end_date: sync.window_end_date
)
end
collect_health_stats(sync, errors: nil)
rescue => e
collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ])
raise
end
def perform_post_sync
# no-op
end
end