class EnableBankingItem < ApplicationRecord include Syncable, Provided, Unlinking, Encryptable enum :status, { good: "good", requires_update: "requires_update" }, default: :good # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured if encryption_ready? encrypts :client_certificate, deterministic: true encrypts :session_id, deterministic: true encrypts :raw_payload encrypts :raw_institution_payload end validates :name, presence: true validates :country_code, presence: true validates :application_id, presence: true validates :client_certificate, presence: true, on: :create belongs_to :family has_one_attached :logo, dependent: :purge_later has_many :enable_banking_accounts, dependent: :destroy has_many :accounts, through: :enable_banking_accounts scope :active, -> { where(scheduled_for_deletion: false) } scope :syncable, -> { active } scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } def destroy_later update!(scheduled_for_deletion: true) DestroyJob.perform_later(self) end def credentials_configured? application_id.present? && client_certificate.present? && country_code.present? end def session_valid? session_id.present? && (session_expires_at.nil? || session_expires_at > Time.current) end def session_expired? session_id.present? && session_expires_at.present? && session_expires_at <= Time.current end def needs_authorization? !session_valid? end # TODO: implement data retention policy for last_psu_ip (GDPR/CCPA — nullify after session expiry or 90 days) validate :psu_type_in_aspsp_types def psu_type_in_aspsp_types return if psu_type.blank? || aspsp_psu_types.blank? unless aspsp_psu_types.include?(psu_type) errors.add(:psu_type, "must be one of the ASPSP supported types") end end # Start the OAuth authorization flow # @param aspsp_name [String] Name of the selected ASPSP # @param redirect_url [String] Callback URL # @param state [String, nil] State parameter (passed through to callback) # @param psu_type [String] "personal" or "business" # @param aspsp_data [Hash, nil] Full ASPSP object from GET /aspsps (used to store metadata) # @param language [String, nil] Two-letter language code # @return [String] Redirect URL for the user def start_authorization(aspsp_name:, redirect_url:, state: nil, psu_type: "personal", aspsp_data: nil, language: nil) provider = enable_banking_provider raise StandardError.new("Enable Banking provider is not configured") unless provider validated_psu_type = psu_type selected_method = nil # Store ASPSP metadata before calling provider so it's available even if auth fails if aspsp_data.present? aspsp_data = aspsp_data.with_indifferent_access aspsp_types = Array(aspsp_data[:psu_types]).map(&:to_s) # If the requested PSU type isn't supported by this ASPSP, fall back to the # first type it advertises rather than failing outright. validated_psu_type = if psu_type.present? && aspsp_types.include?(psu_type) psu_type elsif aspsp_types.any? aspsp_types.first else psu_type end selected_method = select_auth_method(aspsp_data, validated_psu_type) update!( aspsp_required_psu_headers: aspsp_data[:required_psu_headers] || [], aspsp_maximum_consent_validity: aspsp_data[:maximum_consent_validity], aspsp_auth_approach: selected_method&.dig(:approach), aspsp_psu_types: aspsp_types ) end result = provider.start_authorization( aspsp_name: aspsp_name, aspsp_country: country_code, redirect_url: redirect_url, state: state, psu_type: validated_psu_type, maximum_consent_validity: aspsp_maximum_consent_validity, language: language, auth_method: selected_method&.dig(:name) ) attributes = { authorization_id: result[:authorization_id], aspsp_name: aspsp_name } attributes[:psu_type] = validated_psu_type if validated_psu_type.present? update!(attributes) result[:url] end # Shared entry point for both initial authorization and reauthorization. # Re-fetches ASPSP metadata (so the auth method / PSU type selection and the # stored approach stay accurate) and starts the provider authorization. The # re-fetch — rather than caching the full ASPSP object in the session — keeps # us under the 4KB session cookie limit. # @return [String] Redirect URL for the user def begin_authorization!(redirect_url:, state:, language: nil, psu_type: nil, aspsp_name: nil) name = aspsp_name.presence || self.aspsp_name raise StandardError.new("No bank selected for this connection") if name.blank? start_authorization( aspsp_name: name, redirect_url: redirect_url, state: state, psu_type: psu_type.presence || self.psu_type || "personal", aspsp_data: fetch_aspsp_data(name), language: language ) end # Complete the authorization flow with the code from callback def complete_authorization(code:) provider = enable_banking_provider raise StandardError.new("Enable Banking provider is not configured") unless provider result = provider.create_session(code: code) # Store session information update!( session_id: result[:session_id], session_expires_at: parse_session_expiry(result), authorization_id: nil, # Clear the authorization ID status: :good ) # Import the accounts from the session import_accounts_from_session(result[:accounts] || []) result end # Reconcile the locally-stored session expiry with what the API reports. # The session info returned by GET /sessions carries the authoritative # access.valid_until; persisting it on every sync keeps session_valid? accurate # and avoids both premature "expired" states and stale "still valid" states. def reconcile_session_expiry!(session_data) return unless session_data.is_a?(Hash) valid_until = session_data.dig(:access, :valid_until) || session_data.dig("access", "valid_until") return if valid_until.blank? parsed = Time.zone.parse(valid_until.to_s) return if parsed.nil? || parsed == session_expires_at update!(session_expires_at: parsed) rescue ArgumentError, TypeError, ActiveRecord::ActiveRecordError => e # Best-effort reconciliation: swallow bad timestamps (ArgumentError/TypeError) # as well as validation/locking failures from update! (RecordInvalid, # StaleObjectError) so a sync is never derailed by expiry bookkeeping. Rails.logger.warn "EnableBankingItem #{id} - Failed to reconcile session expiry: #{e.message}" end def import_latest_enable_banking_data provider = enable_banking_provider unless provider Rails.logger.error "EnableBankingItem #{id} - Cannot import: Enable Banking provider is not configured" raise StandardError.new("Enable Banking provider is not configured") end unless session_valid? Rails.logger.error "EnableBankingItem #{id} - Cannot import: Session is not valid" update!(status: :requires_update) raise StandardError.new("Enable Banking session is not valid or has expired") end EnableBankingItem::Importer.new(self, enable_banking_provider: provider).import rescue => e Rails.logger.error "EnableBankingItem #{id} - Failed to import data: #{e.message}" raise end def process_accounts return [] if enable_banking_accounts.empty? results = [] enable_banking_accounts.joins(:account).merge(Account.visible).each do |enable_banking_account| begin result = EnableBankingAccount::Processor.new(enable_banking_account).process results << { enable_banking_account_id: enable_banking_account.id, success: true, result: result } rescue => e Rails.logger.error "EnableBankingItem #{id} - Failed to process account #{enable_banking_account.id}: #{e.message}" results << { enable_banking_account_id: enable_banking_account.id, success: false, error: e.message } end end results end def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) return [] if accounts.empty? results = [] accounts.visible.each do |account| begin account.sync_later( parent_sync: parent_sync, window_start_date: window_start_date, window_end_date: window_end_date ) results << { account_id: account.id, success: true } rescue => e Rails.logger.error "EnableBankingItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" results << { account_id: account.id, success: false, error: e.message } end end results end def upsert_enable_banking_snapshot!(accounts_snapshot) assign_attributes( raw_payload: accounts_snapshot ) save! end def has_completed_initial_setup? accounts.any? end def linked_accounts_count enable_banking_accounts.joins(:account_provider).count end def unlinked_accounts_count enable_banking_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count end def total_accounts_count enable_banking_accounts.count end def sync_status_summary latest = latest_sync return nil unless latest if latest.sync_stats.present? stats = latest.sync_stats total = stats["total_accounts"] || 0 linked = stats["linked_accounts"] || 0 unlinked = stats["unlinked_accounts"] || 0 if total == 0 "No accounts found" elsif unlinked == 0 "#{linked} #{'account'.pluralize(linked)} synced" else "#{linked} synced, #{unlinked} need setup" end else total_accounts = enable_banking_accounts.count linked_count = accounts.count unlinked_count = total_accounts - linked_count if total_accounts == 0 "No accounts found" elsif unlinked_count == 0 "#{linked_count} #{'account'.pluralize(linked_count)} synced" else "#{linked_count} synced, #{unlinked_count} need setup" end end end def institution_display_name aspsp_name.presence || institution_name.presence || institution_domain.presence || name end def connected_institutions enable_banking_accounts.includes(:account) .where.not(institution_metadata: nil) .map { |acc| acc.institution_metadata } .uniq { |inst| inst["name"] || inst["institution_name"] } end def institution_summary institutions = connected_institutions case institutions.count when 0 aspsp_name.presence || "No institutions connected" when 1 institutions.first["name"] || institutions.first["institution_name"] || "1 institution" else "#{institutions.count} institutions" end end # Revoke the session with Enable Banking def revoke_session return unless session_id.present? provider = enable_banking_provider return unless provider begin provider.delete_session(session_id: session_id) rescue Provider::EnableBanking::EnableBankingError => e Rails.logger.warn "EnableBankingItem #{id} - Failed to revoke session: #{e.message}" ensure update!( session_id: nil, session_expires_at: nil, authorization_id: nil ) end end private # Authentication approach preference, lowest number wins. # REDIRECT is the smoothest (PSU authenticates entirely on the ASPSP page). # DECOUPLED works through Enable Banking's hosted page (push-to-app / photoTAN # / chipTAN). EMBEDDED is last resort (handled by the hosted page too). AUTH_APPROACH_PRIORITY = { "REDIRECT" => 0, "DECOUPLED" => 1, "EMBEDDED" => 2 }.freeze # Choose the best authentication method for the given PSU type. # Returns a hash with :name and :approach, or nil when the ASPSP exposes no # API-selectable methods (Enable Banking then falls back to its default). def select_auth_method(aspsp_data, psu_type) methods = Array(aspsp_data[:auth_methods]).map(&:with_indifferent_access) return nil if methods.empty? # Hidden methods aren't surfaced on Enable Banking's hosted page, so we don't # auto-select one (the PSU couldn't complete it). If every method is hidden, # return nil and let /auth fall back to the ASPSP's default rather than # forcing a non-selectable method. methods = methods.reject { |m| ActiveModel::Type::Boolean.new.cast(m[:hidden_method]) } return nil if methods.empty? # Prefer methods that match the chosen PSU type; if none declare a psu_type # (or none match), consider all of them. matching = methods.select { |m| m[:psu_type].blank? || m[:psu_type].to_s == psu_type.to_s } candidates = matching.presence || methods best = candidates.min_by { |m| AUTH_APPROACH_PRIORITY.fetch(m[:approach].to_s, 99) } return nil unless best { name: best[:name], approach: best[:approach] } end # Fetch the ASPSP object for a given name from the provider's /aspsps list. # Returns a HashWithIndifferentAccess, or nil if unavailable. def fetch_aspsp_data(aspsp_name) provider = enable_banking_provider return nil unless provider response = provider.get_aspsps(country: country_code) raw_aspsps = response[:aspsps] || response["aspsps"] || [] raw_aspsps.find { |a| (a[:name] || a["name"]) == aspsp_name }&.with_indifferent_access rescue Provider::EnableBanking::EnableBankingError => e Rails.logger.warn "EnableBankingItem #{id} - could not fetch ASPSP metadata for #{aspsp_name}: #{e.message}" nil end def parse_session_expiry(session_result) if session_result[:access].present? && session_result[:access][:valid_until].present? parsed = Time.zone.parse(session_result[:access][:valid_until]) parsed || 90.days.from_now else 90.days.from_now end rescue ArgumentError, TypeError => e Rails.logger.warn "EnableBankingItem #{id} - Failed to parse session expiry: #{e.message}" 90.days.from_now end def import_accounts_from_session(accounts_data) return if accounts_data.blank? accounts_data.each do |account_data| # Use identification_hash as the stable identifier across sessions uid = account_data[:identification_hash] || account_data[:uid] next unless uid.present? enable_banking_account = enable_banking_accounts.find_or_initialize_by(uid: uid) enable_banking_account.upsert_enable_banking_snapshot!(account_data) enable_banking_account.save! end end end