diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 0a31eb593..4940ea69c 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -9,6 +9,7 @@ class AccountsController < ApplicationController @plaid_items = family.plaid_items.ordered @simplefin_items = family.simplefin_items.ordered.includes(:syncs) @lunchflow_items = family.lunchflow_items.ordered + @enable_banking_items = family.enable_banking_items.ordered.includes(:syncs) # Precompute per-item maps to avoid queries in the view @simplefin_sync_stats_map = {} diff --git a/app/controllers/enable_banking_items_controller.rb b/app/controllers/enable_banking_items_controller.rb new file mode 100644 index 000000000..95fc35559 --- /dev/null +++ b/app/controllers/enable_banking_items_controller.rb @@ -0,0 +1,469 @@ +class EnableBankingItemsController < ApplicationController + before_action :set_enable_banking_item, only: [ :update, :destroy, :sync, :select_bank, :authorize, :reauthorize, :setup_accounts, :complete_account_setup, :new_connection ] + skip_before_action :verify_authenticity_token, only: [ :callback ] + + def create + @enable_banking_item = Current.family.enable_banking_items.build(enable_banking_item_params) + @enable_banking_item.name ||= "Enable Banking Connection" + + if @enable_banking_item.save + if turbo_frame_request? + flash.now[:notice] = t(".success", default: "Successfully configured Enable Banking.") + @enable_banking_items = Current.family.enable_banking_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "enable_banking-providers-panel", + partial: "settings/providers/enable_banking_panel", + locals: { enable_banking_items: @enable_banking_items } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + else + @error_message = @enable_banking_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "enable_banking-providers-panel", + partial: "settings/providers/enable_banking_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity + end + end + end + + def update + if @enable_banking_item.update(enable_banking_item_params) + if turbo_frame_request? + flash.now[:notice] = t(".success", default: "Successfully updated Enable Banking configuration.") + @enable_banking_items = Current.family.enable_banking_items.ordered + render turbo_stream: [ + turbo_stream.replace( + "enable_banking-providers-panel", + partial: "settings/providers/enable_banking_panel", + locals: { enable_banking_items: @enable_banking_items } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + else + @error_message = @enable_banking_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "enable_banking-providers-panel", + partial: "settings/providers/enable_banking_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity + end + end + end + + def destroy + # Ensure we detach provider links before scheduling deletion + begin + @enable_banking_item.unlink_all!(dry_run: false) + rescue => e + Rails.logger.warn("Enable Banking unlink during destroy failed: #{e.class} - #{e.message}") + end + @enable_banking_item.revoke_session + @enable_banking_item.destroy_later + redirect_to settings_providers_path, notice: t(".success", default: "Scheduled Enable Banking connection for deletion.") + end + + def sync + unless @enable_banking_item.syncing? + @enable_banking_item.sync_later + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + # Show bank selection page + def select_bank + unless @enable_banking_item.credentials_configured? + redirect_to settings_providers_path, alert: t(".credentials_required", default: "Please configure your Enable Banking credentials first.") + return + end + + # Track if this is for creating a new connection (vs re-authorizing existing) + @new_connection = params[:new_connection] == "true" + + begin + provider = @enable_banking_item.enable_banking_provider + response = provider.get_aspsps(country: @enable_banking_item.country_code) + # API returns { aspsps: [...] }, extract the array + @aspsps = response[:aspsps] || response["aspsps"] || [] + rescue Provider::EnableBanking::EnableBankingError => e + Rails.logger.error "Enable Banking API error in select_bank: #{e.message}" + @error_message = e.message + @aspsps = [] + end + + render layout: false + end + + # Initiate authorization for a selected bank + def authorize + aspsp_name = params[:aspsp_name] + + unless aspsp_name.present? + redirect_to settings_providers_path, alert: t(".bank_required", default: "Please select a bank.") + return + end + + begin + # If this is a new connection request, create the item now (when user has selected a bank) + target_item = if params[:new_connection] == "true" + Current.family.enable_banking_items.create!( + name: "Enable Banking Connection", + country_code: @enable_banking_item.country_code, + application_id: @enable_banking_item.application_id, + client_certificate: @enable_banking_item.client_certificate + ) + else + @enable_banking_item + end + + redirect_url = target_item.start_authorization( + aspsp_name: aspsp_name, + redirect_url: enable_banking_callback_url, + state: target_item.id + ) + + safe_redirect_to_enable_banking( + redirect_url, + fallback_path: settings_providers_path, + fallback_alert: t(".invalid_redirect", default: "Invalid authorization URL received. Please try again or contact support.") + ) + rescue Provider::EnableBanking::EnableBankingError => e + if e.message.include?("REDIRECT_URI_NOT_ALLOWED") + Rails.logger.error "Enable Banking redirect URI not allowed: #{e.message}" + redirect_to settings_providers_path, alert: t(".redirect_uri_not_allowed", default: "Redirect not allowew. Configure `%{callback_url}` in your Enable Banking application settings.", callback_url: enable_banking_callback_url) + else + Rails.logger.error "Enable Banking authorization error: #{e.message}" + redirect_to settings_providers_path, alert: t(".authorization_failed", default: "Failed to start authorization: %{message}", message: e.message) + end + rescue => e + Rails.logger.error "Unexpected error in authorize: #{e.class}: #{e.message}" + redirect_to settings_providers_path, alert: t(".unexpected_error", default: "An unexpected error occurred. Please try again.") + end + end + + # Handle OAuth callback from Enable Banking + def callback + code = params[:code] + state = params[:state] + error = params[:error] + error_description = params[:error_description] + + if error.present? + Rails.logger.error "Enable Banking callback error: #{error} - #{error_description}" + redirect_to settings_providers_path, alert: t(".authorization_error", default: "Authorization failed: %{error}", error: error_description || error) + return + end + + unless code.present? && state.present? + redirect_to settings_providers_path, alert: t(".invalid_callback", default: "Invalid callback parameters.") + return + end + + # Find the enable_banking_item by ID from state + enable_banking_item = Current.family.enable_banking_items.find_by(id: state) + + unless enable_banking_item.present? + redirect_to settings_providers_path, alert: t(".item_not_found", default: "Connection not found.") + return + end + + begin + enable_banking_item.complete_authorization(code: code) + + # Trigger sync to process accounts + enable_banking_item.sync_later + + redirect_to accounts_path, notice: t(".success", default: "Successfully connected to your bank. Your accounts are being synced.") + rescue Provider::EnableBanking::EnableBankingError => e + Rails.logger.error "Enable Banking session creation error: #{e.message}" + redirect_to settings_providers_path, alert: t(".session_failed", default: "Failed to complete authorization: %{message}", message: e.message) + rescue => e + Rails.logger.error "Unexpected error in callback: #{e.class}: #{e.message}" + redirect_to settings_providers_path, alert: t(".unexpected_error", default: "An unexpected error occurred. Please try again.") + end + end + + # Show bank selection for a new connection using credentials from an existing item + # Does NOT create a new item - that happens in authorize when user selects a bank + def new_connection + # Redirect to select_bank with a flag indicating this is for a new connection + redirect_to select_bank_enable_banking_item_path(@enable_banking_item, new_connection: true), data: { turbo_frame: "modal" } + end + + # Re-authorize an expired session + def reauthorize + begin + redirect_url = @enable_banking_item.start_authorization( + aspsp_name: @enable_banking_item.aspsp_name, + redirect_url: enable_banking_callback_url, + state: @enable_banking_item.id + ) + + safe_redirect_to_enable_banking( + redirect_url, + fallback_path: settings_providers_path, + fallback_alert: t(".invalid_redirect", default: "Invalid authorization URL received. Please try again or contact support.") + ) + rescue Provider::EnableBanking::EnableBankingError => e + Rails.logger.error "Enable Banking reauthorization error: #{e.message}" + redirect_to settings_providers_path, alert: t(".reauthorization_failed", default: "Failed to re-authorize: %{message}", message: e.message) + end + end + + # Link accounts from Enable Banking to internal accounts + def link_accounts + selected_uids = params[:account_uids] || [] + accountable_type = params[:accountable_type] || "Depository" + + if selected_uids.empty? + redirect_to accounts_path, alert: t(".no_accounts_selected", default: "No accounts selected.") + return + end + + enable_banking_item = Current.family.enable_banking_items.where.not(session_id: nil).first + + unless enable_banking_item.present? + redirect_to settings_providers_path, alert: t(".no_session", default: "No active Enable Banking connection. Please connect a bank first.") + return + end + + created_accounts = [] + already_linked_accounts = [] + + # Wrap in transaction so partial failures don't leave orphaned accounts without provider links + begin + ActiveRecord::Base.transaction do + selected_uids.each do |uid| + enable_banking_account = enable_banking_item.enable_banking_accounts.find_by(uid: uid) + next unless enable_banking_account + + # Check if already linked + if enable_banking_account.account_provider.present? + already_linked_accounts << enable_banking_account.name + next + end + + # Create the internal Account (uses save! internally, will raise on failure) + account = Account.create_and_sync( + family: Current.family, + name: enable_banking_account.name, + balance: enable_banking_account.current_balance || 0, + currency: enable_banking_account.currency || "EUR", + accountable_type: accountable_type, + accountable_attributes: {} + ) + + # Link account to enable_banking_account via account_providers + # Uses create! so any failure will rollback the entire transaction + AccountProvider.create!( + account: account, + provider: enable_banking_account + ) + + created_accounts << account + end + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error "Enable Banking link_accounts failed: #{e.class} - #{e.message}" + redirect_to accounts_path, alert: t(".link_failed", default: "Failed to link accounts: %{error}", error: e.message) + return + end + + # Trigger sync if accounts were created + enable_banking_item.sync_later if created_accounts.any? + + if created_accounts.any? + redirect_to accounts_path, notice: t(".success", default: "%{count} account(s) linked successfully.", count: created_accounts.count) + elsif already_linked_accounts.any? + redirect_to accounts_path, alert: t(".already_linked", default: "Selected accounts are already linked.") + else + redirect_to accounts_path, alert: t(".link_failed", default: "Failed to link accounts.") + end + end + + # Show setup accounts modal + def setup_accounts + @enable_banking_accounts = @enable_banking_item.enable_banking_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + + @account_type_options = [ + [ "Skip this account", "skip" ], + [ "Checking or Savings Account", "Depository" ], + [ "Credit Card", "CreditCard" ], + [ "Investment Account", "Investment" ], + [ "Loan or Mortgage", "Loan" ], + [ "Other Asset", "OtherAsset" ] + ] + + @subtype_options = { + "Depository" => { + label: "Account Subtype:", + options: Depository::SUBTYPES.map { |k, v| [ v[:long], k ] } + }, + "CreditCard" => { + label: "", + options: [], + message: "Credit cards will be automatically set up as credit card accounts." + }, + "Investment" => { + label: "Investment Type:", + options: Investment::SUBTYPES.map { |k, v| [ v[:long], k ] } + }, + "Loan" => { + label: "Loan Type:", + options: Loan::SUBTYPES.map { |k, v| [ v[:long], k ] } + }, + "OtherAsset" => { + label: nil, + options: [], + message: "Other assets will be set up as general assets." + } + } + + render layout: false + end + + # Complete account setup from modal + def complete_account_setup + account_types = params[:account_types] || {} + account_subtypes = params[:account_subtypes] || {} + + # Update sync start date from form if provided + if params[:sync_start_date].present? + @enable_banking_item.update!(sync_start_date: params[:sync_start_date]) + end + + created_count = 0 + skipped_count = 0 + + account_types.each do |enable_banking_account_id, selected_type| + # Skip accounts marked as "skip" + if selected_type == "skip" || selected_type.blank? + skipped_count += 1 + next + end + + enable_banking_account = @enable_banking_item.enable_banking_accounts.find(enable_banking_account_id) + selected_subtype = account_subtypes[enable_banking_account_id] + + # Default subtype for CreditCard since it only has one option + selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank? + + # Create account with user-selected type and subtype + account = Account.create_from_enable_banking_account( + enable_banking_account, + selected_type, + selected_subtype + ) + + # Link account via AccountProvider + AccountProvider.create!( + account: account, + provider: enable_banking_account + ) + + created_count += 1 + end + + # Clear pending status and mark as complete + @enable_banking_item.update!(pending_account_setup: false) + + # Trigger a sync to process the imported data if accounts were created + @enable_banking_item.sync_later if created_count > 0 + + if created_count > 0 + flash[:notice] = t(".success", default: "%{count} account(s) created successfully!", count: created_count) + elsif skipped_count > 0 + flash[:notice] = t(".all_skipped", default: "All accounts were skipped. You can set them up later from the accounts page.") + else + flash[:notice] = t(".no_accounts", default: "No accounts to set up.") + end + + redirect_to accounts_path, status: :see_other + end + + private + + def set_enable_banking_item + @enable_banking_item = Current.family.enable_banking_items.find(params[:id]) + end + + def enable_banking_item_params + params.require(:enable_banking_item).permit( + :name, + :sync_start_date, + :country_code, + :application_id, + :client_certificate + ) + end + + # Generate the callback URL for Enable Banking OAuth + # In production, uses the standard Rails route + # In development, uses DEV_WEBHOOKS_URL if set (e.g., ngrok URL) + def enable_banking_callback_url + return callback_enable_banking_items_url if Rails.env.production? + + ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/enable_banking_items/callback" + end + + # Validate redirect URLs from Enable Banking API to prevent open redirect attacks + # Only allows HTTPS URLs from trusted Enable Banking domains + TRUSTED_ENABLE_BANKING_HOSTS = %w[ + enablebanking.com + api.enablebanking.com + auth.enablebanking.com + ].freeze + + def valid_enable_banking_redirect_url?(url) + return false if url.blank? + + begin + uri = URI.parse(url) + + # Must be HTTPS + return false unless uri.scheme == "https" + + # Host must be present + return false if uri.host.blank? + + # Check if host matches or is a subdomain of trusted domains + TRUSTED_ENABLE_BANKING_HOSTS.any? do |trusted_host| + uri.host == trusted_host || uri.host.end_with?(".#{trusted_host}") + end + rescue URI::InvalidURIError => e + Rails.logger.warn("Enable Banking invalid redirect URL: #{url.inspect} - #{e.message}") + false + end + end + + def safe_redirect_to_enable_banking(redirect_url, fallback_path:, fallback_alert:) + if valid_enable_banking_redirect_url?(redirect_url) + redirect_to redirect_url, allow_other_host: true + else + Rails.logger.warn("Enable Banking redirect blocked - invalid URL: #{redirect_url.inspect}") + redirect_to fallback_path, alert: fallback_alert + end + end +end diff --git a/app/controllers/settings/bank_sync_controller.rb b/app/controllers/settings/bank_sync_controller.rb index fd0f6e2d8..0331e19c6 100644 --- a/app/controllers/settings/bank_sync_controller.rb +++ b/app/controllers/settings/bank_sync_controller.rb @@ -23,6 +23,13 @@ class Settings::BankSyncController < ApplicationController path: "https://beta-bridge.simplefin.org", target: "_blank", rel: "noopener noreferrer" + }, + { + name: "Enable Banking (beta)", + description: "European bank connections via open banking APIs across multiple countries.", + path: "https://enablebanking.com", + target: "_blank", + rel: "noopener noreferrer" } ] end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 0ba123eef..43cd1eed1 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -123,11 +123,14 @@ class Settings::ProvidersController < ApplicationController # Load all provider configurations (exclude SimpleFin and Lunchflow, which have their own family-specific panels below) Provider::Factory.ensure_adapters_loaded @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| - config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? + config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \ + config.provider_key.to_s.casecmp("enable_banking").zero? end # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials @simplefin_items = Current.family.simplefin_items.where.not(access_url: [ nil, "" ]).ordered.select(:id) @lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id) + # Enable Banking panel needs session info for status display + @enable_banking_items = Current.family.enable_banking_items.ordered end end diff --git a/app/models/account.rb b/app/models/account.rb index 1b9b5e23a..2f1508494 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -133,6 +133,37 @@ class Account < ApplicationRecord create_and_sync(attributes) end + def create_from_enable_banking_account(enable_banking_account, account_type, subtype = nil) + # Get the balance from Enable Banking + balance = enable_banking_account.current_balance || 0 + + # Enable Banking may return negative balances for liabilities + # Sure expects positive balances for liabilities + if account_type == "CreditCard" || account_type == "Loan" + balance = balance.abs + end + + cash_balance = balance + + attributes = { + family: enable_banking_account.enable_banking_item.family, + name: enable_banking_account.name, + balance: balance, + cash_balance: cash_balance, + currency: enable_banking_account.currency || "EUR" + } + + accountable_attributes = {} + accountable_attributes[:subtype] = subtype if subtype.present? + + create_and_sync( + attributes.merge( + accountable_type: account_type, + accountable_attributes: accountable_attributes + ) + ) + end + private diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index 8324ccd91..c730e3693 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -1,5 +1,5 @@ class DataEnrichment < ApplicationRecord belongs_to :enrichable, polymorphic: true - enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai" } + enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking" } end diff --git a/app/models/enable_banking_account.rb b/app/models/enable_banking_account.rb new file mode 100644 index 000000000..6bc356bc9 --- /dev/null +++ b/app/models/enable_banking_account.rb @@ -0,0 +1,130 @@ +class EnableBankingAccount < ApplicationRecord + include CurrencyNormalizable + + belongs_to :enable_banking_item + + # New association through account_providers + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + validates :uid, presence: true, uniqueness: { scope: :enable_banking_item_id } + + # Helper to get account using account_providers system + def current_account + account + end + + # Returns the API account ID (UUID) for Enable Banking API calls + # The Enable Banking API requires a valid UUID for balance/transaction endpoints + # Falls back to raw_payload["uid"] for existing accounts that have the wrong account_id stored + def api_account_id + # Check if account_id looks like a valid UUID (not an identification_hash) + if account_id.present? && account_id.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i) + account_id + else + # Fall back to raw_payload for existing accounts with incorrect account_id + raw_payload&.dig("uid") || account_id || uid + end + end + + # Map PSD2 cash_account_type codes to user-friendly names + # Based on ISO 20022 External Cash Account Type codes + def account_type_display + return nil unless account_type.present? + + type_mappings = { + "CACC" => "Current/Checking Account", + "SVGS" => "Savings Account", + "CARD" => "Card Account", + "CRCD" => "Credit Card", + "LOAN" => "Loan Account", + "MORT" => "Mortgage Account", + "ODFT" => "Overdraft Account", + "CASH" => "Cash Account", + "TRAN" => "Transacting Account", + "SALA" => "Salary Account", + "MOMA" => "Money Market Account", + "NREX" => "Non-Resident External Account", + "TAXE" => "Tax Account", + "TRAS" => "Cash Trading Account", + "ONDP" => "Overnight Deposit" + } + + type_mappings[account_type.upcase] || account_type.titleize + end + + def upsert_enable_banking_snapshot!(account_snapshot) + # Convert to symbol keys or handle both string and symbol keys + snapshot = account_snapshot.with_indifferent_access + + # Map Enable Banking field names to our field names + # Enable Banking API returns: { uid, iban, account_id: { iban }, currency, cash_account_type, ... } + # account_id can be a hash with iban, or an array of account identifiers + raw_account_id = snapshot[:account_id] + account_id_data = if raw_account_id.is_a?(Hash) + raw_account_id + elsif raw_account_id.is_a?(Array) && raw_account_id.first.is_a?(Hash) + # If it's an array of hashes, find the one with iban + raw_account_id.find { |item| item[:iban].present? } || {} + else + {} + end + + update!( + current_balance: nil, # Balance fetched separately via /accounts/{uid}/balances + currency: parse_currency(snapshot[:currency]) || "EUR", + name: build_account_name(snapshot), + # account_id stores the API UUID for fetching balances/transactions + account_id: snapshot[:uid], + # uid is the stable identifier (identification_hash) for matching accounts across sessions + uid: snapshot[:identification_hash] || snapshot[:uid], + iban: account_id_data[:iban] || snapshot[:iban], + account_type: snapshot[:cash_account_type] || snapshot[:account_type], + account_status: "active", + provider: "enable_banking", + institution_metadata: { + name: enable_banking_item&.aspsp_name, + aspsp_name: enable_banking_item&.aspsp_name + }.compact, + raw_payload: account_snapshot + ) + end + + def upsert_enable_banking_transactions_snapshot!(transactions_snapshot) + assign_attributes( + raw_transactions_payload: transactions_snapshot + ) + + save! + end + + private + + def build_account_name(snapshot) + # Try to build a meaningful name from the account data + raw_account_id = snapshot[:account_id] + account_id_data = if raw_account_id.is_a?(Hash) + raw_account_id + elsif raw_account_id.is_a?(Array) && raw_account_id.first.is_a?(Hash) + raw_account_id.find { |item| item[:iban].present? } || {} + else + {} + end + iban = account_id_data[:iban] || snapshot[:iban] + + if snapshot[:name].present? + snapshot[:name] + elsif iban.present? + # Use last 4 digits of IBAN for privacy + "Account ...#{iban[-4..]}" + else + "Enable Banking Account" + end + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for EnableBanking account #{id}, defaulting to EUR") + end +end diff --git a/app/models/enable_banking_account/processor.rb b/app/models/enable_banking_account/processor.rb new file mode 100644 index 000000000..3b5741b17 --- /dev/null +++ b/app/models/enable_banking_account/processor.rb @@ -0,0 +1,69 @@ +class EnableBankingAccount::Processor + include CurrencyNormalizable + + attr_reader :enable_banking_account + + def initialize(enable_banking_account) + @enable_banking_account = enable_banking_account + end + + def process + unless enable_banking_account.current_account.present? + Rails.logger.info "EnableBankingAccount::Processor - No linked account for enable_banking_account #{enable_banking_account.id}, skipping processing" + return + end + + Rails.logger.info "EnableBankingAccount::Processor - Processing enable_banking_account #{enable_banking_account.id} (uid #{enable_banking_account.uid})" + + begin + process_account! + rescue StandardError => e + Rails.logger.error "EnableBankingAccount::Processor - Failed to process account #{enable_banking_account.id}: #{e.message}" + Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}" + report_exception(e, "account") + raise + end + + process_transactions + end + + private + + def process_account! + if enable_banking_account.current_account.blank? + Rails.logger.error("Enable Banking account #{enable_banking_account.id} has no associated Account") + return + end + + account = enable_banking_account.current_account + balance = enable_banking_account.current_balance || 0 + + # For liability accounts, ensure positive balances + if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" + balance = -balance + end + + currency = parse_currency(enable_banking_account.currency) || account.currency || "EUR" + + account.update!( + balance: balance, + cash_balance: balance, + currency: currency + ) + end + + def process_transactions + EnableBankingAccount::Transactions::Processor.new(enable_banking_account).process + rescue => e + report_exception(e, "transactions") + end + + def report_exception(error, context) + Sentry.capture_exception(error) do |scope| + scope.set_tags( + enable_banking_account_id: enable_banking_account.id, + context: context + ) + end + end +end diff --git a/app/models/enable_banking_account/transactions/processor.rb b/app/models/enable_banking_account/transactions/processor.rb new file mode 100644 index 000000000..9791ad96f --- /dev/null +++ b/app/models/enable_banking_account/transactions/processor.rb @@ -0,0 +1,66 @@ +class EnableBankingAccount::Transactions::Processor + attr_reader :enable_banking_account + + def initialize(enable_banking_account) + @enable_banking_account = enable_banking_account + end + + def process + unless enable_banking_account.raw_transactions_payload.present? + Rails.logger.info "EnableBankingAccount::Transactions::Processor - No transactions in raw_transactions_payload for enable_banking_account #{enable_banking_account.id}" + return { success: true, total: 0, imported: 0, failed: 0, errors: [] } + end + + total_count = enable_banking_account.raw_transactions_payload.count + Rails.logger.info "EnableBankingAccount::Transactions::Processor - Processing #{total_count} transactions for enable_banking_account #{enable_banking_account.id}" + + imported_count = 0 + failed_count = 0 + errors = [] + + enable_banking_account.raw_transactions_payload.each_with_index do |transaction_data, index| + begin + result = EnableBankingEntry::Processor.new( + transaction_data, + enable_banking_account: enable_banking_account + ).process + + if result.nil? + failed_count += 1 + errors << { index: index, transaction_id: transaction_data[:transaction_id], error: "No linked account" } + else + imported_count += 1 + end + rescue ArgumentError => e + failed_count += 1 + transaction_id = transaction_data.try(:[], :transaction_id) || transaction_data.try(:[], "transaction_id") || "unknown" + error_message = "Validation error: #{e.message}" + Rails.logger.error "EnableBankingAccount::Transactions::Processor - #{error_message} (transaction #{transaction_id})" + errors << { index: index, transaction_id: transaction_id, error: error_message } + rescue => e + failed_count += 1 + transaction_id = transaction_data.try(:[], :transaction_id) || transaction_data.try(:[], "transaction_id") || "unknown" + error_message = "#{e.class}: #{e.message}" + Rails.logger.error "EnableBankingAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}" + Rails.logger.error e.backtrace.join("\n") + errors << { index: index, transaction_id: transaction_id, error: error_message } + end + end + + result = { + success: failed_count == 0, + total: total_count, + imported: imported_count, + failed: failed_count, + errors: errors + } + + if failed_count > 0 + Rails.logger.warn "EnableBankingAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions" + else + Rails.logger.info "EnableBankingAccount::Transactions::Processor - Successfully processed #{imported_count} transactions" + end + + result + end +end diff --git a/app/models/enable_banking_entry/processor.rb b/app/models/enable_banking_entry/processor.rb new file mode 100644 index 000000000..2429f27ce --- /dev/null +++ b/app/models/enable_banking_entry/processor.rb @@ -0,0 +1,196 @@ +require "digest/md5" + +class EnableBankingEntry::Processor + include CurrencyNormalizable + + # enable_banking_transaction is the raw hash fetched from Enable Banking API + # Transaction structure from Enable Banking: + # { + # transaction_id, entry_reference, booking_date, value_date, + # transaction_amount: { amount, currency }, + # creditor_name, debtor_name, remittance_information, ... + # } + def initialize(enable_banking_transaction, enable_banking_account:) + @enable_banking_transaction = enable_banking_transaction + @enable_banking_account = enable_banking_account + end + + def process + unless account.present? + Rails.logger.warn "EnableBankingEntry::Processor - No linked account for enable_banking_account #{enable_banking_account.id}, skipping transaction #{external_id}" + return nil + end + + begin + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "enable_banking", + merchant: merchant, + notes: notes + ) + rescue ArgumentError => e + Rails.logger.error "EnableBankingEntry::Processor - Validation error for transaction #{external_id}: #{e.message}" + raise + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error "EnableBankingEntry::Processor - Failed to save transaction #{external_id}: #{e.message}" + raise StandardError.new("Failed to import transaction: #{e.message}") + rescue => e + Rails.logger.error "EnableBankingEntry::Processor - Unexpected error processing transaction #{external_id}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + raise StandardError.new("Unexpected error importing transaction: #{e.message}") + end + end + + private + + attr_reader :enable_banking_transaction, :enable_banking_account + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= enable_banking_account.current_account + end + + def data + @data ||= enable_banking_transaction.with_indifferent_access + end + + def external_id + id = data[:transaction_id].presence || data[:entry_reference].presence + raise ArgumentError, "Enable Banking transaction missing required field 'transaction_id'" unless id + "enable_banking_#{id}" + end + + def name + # Build name from available Enable Banking transaction fields + # Priority: counterparty name > bank_transaction_code description > remittance_information + + # Determine counterparty based on transaction direction + # For outgoing payments (DBIT), counterparty is the creditor (who we paid) + # For incoming payments (CRDT), counterparty is the debtor (who paid us) + counterparty = if credit_debit_indicator == "CRDT" + data.dig(:debtor, :name) || data[:debtor_name] + else + data.dig(:creditor, :name) || data[:creditor_name] + end + + return counterparty if counterparty.present? + + # Fall back to bank_transaction_code description + bank_tx_description = data.dig(:bank_transaction_code, :description) + return bank_tx_description if bank_tx_description.present? + + # Fall back to remittance_information + remittance = data[:remittance_information] + return remittance.first.truncate(100) if remittance.is_a?(Array) && remittance.first.present? + + # Final fallback: use transaction type indicator + credit_debit_indicator == "CRDT" ? "Incoming Transfer" : "Outgoing Transfer" + end + + def merchant + # For outgoing payments (DBIT), merchant is the creditor (who we paid) + # For incoming payments (CRDT), merchant is the debtor (who paid us) + merchant_name = if credit_debit_indicator == "CRDT" + data.dig(:debtor, :name) || data[:debtor_name] + else + data.dig(:creditor, :name) || data[:creditor_name] + end + + return nil unless merchant_name.present? + + merchant_name = merchant_name.to_s.strip + return nil if merchant_name.blank? + + merchant_id = Digest::MD5.hexdigest(merchant_name.downcase) + + @merchant ||= begin + import_adapter.find_or_create_merchant( + provider_merchant_id: "enable_banking_merchant_#{merchant_id}", + name: merchant_name, + source: "enable_banking" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "EnableBankingEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + nil + end + end + + def notes + remittance = data[:remittance_information] + return nil unless remittance.is_a?(Array) && remittance.any? + + remittance.join("\n") + end + + def amount_value + @amount_value ||= begin + tx_amount = data[:transaction_amount] || {} + raw_amount = tx_amount[:amount] || data[:amount] || "0" + + absolute_amount = case raw_amount + when String + BigDecimal(raw_amount).abs + when Numeric + BigDecimal(raw_amount.to_s).abs + else + BigDecimal("0") + end + + # CRDT (credit) = money coming in = positive + # DBIT (debit) = money going out = negative + credit_debit_indicator == "CRDT" ? -absolute_amount : absolute_amount + rescue ArgumentError => e + Rails.logger.error "Failed to parse Enable Banking transaction amount: #{raw_amount.inspect} - #{e.message}" + raise + end + end + + def credit_debit_indicator + data[:credit_debit_indicator] + end + + def amount + # Enable Banking uses PSD2 Berlin Group convention: negative = debit (outflow), positive = credit (inflow) + # Sure uses the same convention: negative = expense, positive = income + # Therefore, use the amount as-is from the API without inversion + amount_value + end + + def currency + tx_amount = data[:transaction_amount] || {} + parse_currency(tx_amount[:currency]) || parse_currency(data[:currency]) || account&.currency || "EUR" + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' in Enable Banking transaction #{external_id}, falling back to account currency") + end + + def date + # Prefer booking_date, fall back to value_date + date_value = data[:booking_date] || data[:value_date] + + case date_value + when String + Date.parse(date_value) + when Integer, Float + Time.at(date_value).to_date + when Time, DateTime + date_value.to_date + when Date + date_value + else + Rails.logger.error("Enable Banking transaction has invalid date value: #{date_value.inspect}") + raise ArgumentError, "Invalid date format: #{date_value.inspect}" + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Enable Banking transaction date '#{date_value}': #{e.message}") + raise ArgumentError, "Unable to parse transaction date: #{date_value.inspect}" + end +end diff --git a/app/models/enable_banking_item.rb b/app/models/enable_banking_item.rb new file mode 100644 index 000000000..501df1632 --- /dev/null +++ b/app/models/enable_banking_item.rb @@ -0,0 +1,284 @@ +class EnableBankingItem < ApplicationRecord + include Syncable, Provided, Unlinking + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + # Helper to detect if ActiveRecord Encryption is configured for this app + def self.encryption_ready? + creds_ready = Rails.application.credentials.active_record_encryption.present? + env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? + creds_ready || env_ready + end + + # Encrypt sensitive credentials if ActiveRecord encryption is configured + if encryption_ready? + encrypts :client_certificate, deterministic: true + encrypts :session_id, deterministic: true + 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 + + has_many :enable_banking_accounts, dependent: :destroy + has_many :accounts, through: :enable_banking_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + 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 + + # Start the OAuth authorization flow + # Returns a redirect URL for the user + def start_authorization(aspsp_name:, redirect_url:, state: nil) + provider = enable_banking_provider + raise StandardError.new("Enable Banking provider is not configured") unless provider + + result = provider.start_authorization( + aspsp_name: aspsp_name, + aspsp_country: country_code, + redirect_url: redirect_url, + state: state + ) + + # Store the authorization ID for later use + update!( + authorization_id: result[:authorization_id], + aspsp_name: aspsp_name + ) + + result[:url] + 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 + + 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 + + def parse_session_expiry(session_result) + # Enable Banking sessions typically last 90 days + # The exact expiry depends on the ASPSP consent + if session_result[:access].present? && session_result[:access][:valid_until].present? + Time.parse(session_result[:access][:valid_until]) + else + 90.days.from_now + end + rescue => 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 diff --git a/app/models/enable_banking_item/importer.rb b/app/models/enable_banking_item/importer.rb new file mode 100644 index 000000000..d1cc510a6 --- /dev/null +++ b/app/models/enable_banking_item/importer.rb @@ -0,0 +1,251 @@ +class EnableBankingItem::Importer + # Maximum number of pagination requests to prevent infinite loops + # Enable Banking typically returns ~100 transactions per page, so 100 pages = ~10,000 transactions + MAX_PAGINATION_PAGES = 100 + + attr_reader :enable_banking_item, :enable_banking_provider + + def initialize(enable_banking_item, enable_banking_provider:) + @enable_banking_item = enable_banking_item + @enable_banking_provider = enable_banking_provider + end + + def import + unless enable_banking_item.session_valid? + enable_banking_item.update!(status: :requires_update) + return { success: false, error: "Session expired or invalid", accounts_updated: 0, transactions_imported: 0 } + end + + session_data = fetch_session_data + unless session_data + return { success: false, error: "Failed to fetch session data", accounts_updated: 0, transactions_imported: 0 } + end + + # Store raw payload + begin + enable_banking_item.upsert_enable_banking_snapshot!(session_data) + rescue => e + Rails.logger.error "EnableBankingItem::Importer - Failed to store session snapshot: #{e.message}" + end + + # Update accounts from session + accounts_updated = 0 + accounts_failed = 0 + + if session_data[:accounts].present? + existing_uids = enable_banking_item.enable_banking_accounts + .joins(:account_provider) + .pluck(:uid) + .map(&:to_s) + + # Enable Banking API returns accounts as an array of UIDs (strings) in the session response + # We need to handle both array of strings and array of hashes + session_data[:accounts].each do |account_data| + # Handle both string UIDs and hash objects + # Use identification_hash as the stable identifier across sessions + uid = if account_data.is_a?(String) + account_data + elsif account_data.is_a?(Hash) + (account_data[:identification_hash] || account_data[:uid] || account_data["identification_hash"] || account_data["uid"])&.to_s + else + nil + end + + next unless uid.present? + + # Only update if this account was previously linked + next unless existing_uids.include?(uid) + + begin + # For string UIDs, we don't have account data to update - skip the import_account call + # The account data will be fetched via balances/transactions endpoints + if account_data.is_a?(Hash) + import_account(account_data) + accounts_updated += 1 + end + rescue => e + accounts_failed += 1 + Rails.logger.error "EnableBankingItem::Importer - Failed to update account #{uid}: #{e.message}" + end + end + end + + # Fetch balances and transactions for linked accounts + transactions_imported = 0 + transactions_failed = 0 + + linked_accounts_query = enable_banking_item.enable_banking_accounts.joins(:account_provider).joins(:account).merge(Account.visible) + + linked_accounts_query.each do |enable_banking_account| + begin + fetch_and_update_balance(enable_banking_account) + + result = fetch_and_store_transactions(enable_banking_account) + if result[:success] + transactions_imported += result[:transactions_count] + else + transactions_failed += 1 + end + rescue => e + transactions_failed += 1 + Rails.logger.error "EnableBankingItem::Importer - Failed to process account #{enable_banking_account.uid}: #{e.message}" + end + end + + { + success: accounts_failed == 0 && transactions_failed == 0, + accounts_updated: accounts_updated, + accounts_failed: accounts_failed, + transactions_imported: transactions_imported, + transactions_failed: transactions_failed + } + end + + private + + def fetch_session_data + enable_banking_provider.get_session(session_id: enable_banking_item.session_id) + rescue Provider::EnableBanking::EnableBankingError => e + if e.error_type == :unauthorized || e.error_type == :not_found + enable_banking_item.update!(status: :requires_update) + end + Rails.logger.error "EnableBankingItem::Importer - Enable Banking API error: #{e.message}" + nil + rescue => e + Rails.logger.error "EnableBankingItem::Importer - Unexpected error fetching session: #{e.class} - #{e.message}" + nil + end + + def import_account(account_data) + # Use identification_hash as the stable identifier across sessions + uid = account_data[:identification_hash] || account_data[:uid] + + enable_banking_account = enable_banking_item.enable_banking_accounts.find_by(uid: uid.to_s) + return unless enable_banking_account + + enable_banking_account.upsert_enable_banking_snapshot!(account_data) + enable_banking_account.save! + end + + def fetch_and_update_balance(enable_banking_account) + balance_data = enable_banking_provider.get_account_balances(account_id: enable_banking_account.api_account_id) + + # Enable Banking returns an array of balances + balances = balance_data[:balances] || [] + return if balances.empty? + + # Find the most relevant balance (prefer "closingBooked" or "expected") + balance = balances.find { |b| b[:balance_type] == "closingBooked" } || + balances.find { |b| b[:balance_type] == "expected" } || + balances.first + + if balance.present? + amount = balance.dig(:balance_amount, :amount) || balance[:amount] + currency = balance.dig(:balance_amount, :currency) || balance[:currency] + + if amount.present? + enable_banking_account.update!( + current_balance: amount.to_d, + currency: currency.presence || enable_banking_account.currency + ) + end + end + rescue Provider::EnableBanking::EnableBankingError => e + Rails.logger.error "EnableBankingItem::Importer - Error fetching balance for account #{enable_banking_account.uid}: #{e.message}" + end + + def fetch_and_store_transactions(enable_banking_account) + start_date = determine_sync_start_date(enable_banking_account) + + all_transactions = [] + continuation_key = nil + previous_continuation_key = nil + page_count = 0 + + # Paginate through all transactions with safeguards against infinite loops + loop do + page_count += 1 + + # Safeguard: prevent infinite loops from excessive pagination + if page_count > MAX_PAGINATION_PAGES + Rails.logger.error( + "EnableBankingItem::Importer - Pagination limit exceeded for account #{enable_banking_account.uid}. " \ + "Stopped after #{MAX_PAGINATION_PAGES} pages (#{all_transactions.count} transactions). " \ + "Last continuation_key: #{continuation_key.inspect}" + ) + break + end + + transactions_data = enable_banking_provider.get_account_transactions( + account_id: enable_banking_account.api_account_id, + date_from: start_date, + continuation_key: continuation_key + ) + + transactions = transactions_data[:transactions] || [] + all_transactions.concat(transactions) + + previous_continuation_key = continuation_key + continuation_key = transactions_data[:continuation_key] + + # Safeguard: detect repeated continuation_key (provider returning same key) + if continuation_key.present? && continuation_key == previous_continuation_key + Rails.logger.error( + "EnableBankingItem::Importer - Repeated continuation_key detected for account #{enable_banking_account.uid}. " \ + "Breaking loop after #{page_count} pages (#{all_transactions.count} transactions). " \ + "Repeated key: #{continuation_key.inspect}, last response had #{transactions.count} transactions" + ) + break + end + + break if continuation_key.blank? + end + + transactions_count = all_transactions.count + + if all_transactions.any? + existing_transactions = enable_banking_account.raw_transactions_payload.to_a + existing_ids = existing_transactions.map { |tx| + tx = tx.with_indifferent_access + tx[:transaction_id].presence || tx[:entry_reference].presence + }.compact.to_set + + new_transactions = all_transactions.select do |tx| + # Use transaction_id if present, otherwise fall back to entry_reference + tx_id = tx[:transaction_id].presence || tx[:entry_reference].presence + tx_id.present? && !existing_ids.include?(tx_id) + end + + if new_transactions.any? + enable_banking_account.upsert_enable_banking_transactions_snapshot!(existing_transactions + new_transactions) + end + end + + { success: true, transactions_count: transactions_count } + rescue Provider::EnableBanking::EnableBankingError => e + Rails.logger.error "EnableBankingItem::Importer - Error fetching transactions for account #{enable_banking_account.uid}: #{e.message}" + { success: false, transactions_count: 0, error: e.message } + rescue => e + Rails.logger.error "EnableBankingItem::Importer - Unexpected error fetching transactions for account #{enable_banking_account.uid}: #{e.class} - #{e.message}" + { success: false, transactions_count: 0, error: e.message } + end + + def determine_sync_start_date(enable_banking_account) + has_stored_transactions = enable_banking_account.raw_transactions_payload.to_a.any? + + # Use user-configured sync_start_date if set, otherwise default + user_start_date = enable_banking_item.sync_start_date + + if has_stored_transactions + # For incremental syncs, get transactions from 7 days before last sync + if enable_banking_item.last_synced_at + enable_banking_item.last_synced_at.to_date - 7.days + else + user_start_date || 90.days.ago.to_date + end + else + # Initial sync: use user's configured date or default to 3 months + user_start_date || 3.months.ago.to_date + end + end +end diff --git a/app/models/enable_banking_item/provided.rb b/app/models/enable_banking_item/provided.rb new file mode 100644 index 000000000..bd57d2795 --- /dev/null +++ b/app/models/enable_banking_item/provided.rb @@ -0,0 +1,12 @@ +module EnableBankingItem::Provided + extend ActiveSupport::Concern + + def enable_banking_provider + return nil unless credentials_configured? + + Provider::EnableBanking.new( + application_id: application_id, + client_certificate: client_certificate + ) + end +end diff --git a/app/models/enable_banking_item/sync_complete_event.rb b/app/models/enable_banking_item/sync_complete_event.rb new file mode 100644 index 000000000..7900226c4 --- /dev/null +++ b/app/models/enable_banking_item/sync_complete_event.rb @@ -0,0 +1,25 @@ +class EnableBankingItem::SyncCompleteEvent + attr_reader :enable_banking_item + + def initialize(enable_banking_item) + @enable_banking_item = enable_banking_item + end + + def broadcast + # Update UI with latest account data + enable_banking_item.accounts.each do |account| + account.broadcast_sync_complete + end + + # Update the Enable Banking item view + enable_banking_item.broadcast_replace_to( + enable_banking_item.family, + target: "enable_banking_item_#{enable_banking_item.id}", + partial: "enable_banking_items/enable_banking_item", + locals: { enable_banking_item: enable_banking_item } + ) + + # Let family handle sync notifications + enable_banking_item.family.broadcast_sync_complete + end +end diff --git a/app/models/enable_banking_item/syncer.rb b/app/models/enable_banking_item/syncer.rb new file mode 100644 index 000000000..a3cc283a0 --- /dev/null +++ b/app/models/enable_banking_item/syncer.rb @@ -0,0 +1,62 @@ +class EnableBankingItem::Syncer + attr_reader :enable_banking_item + + def initialize(enable_banking_item) + @enable_banking_item = enable_banking_item + end + + def perform_sync(sync) + # Check if session is valid before syncing + 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) + raise StandardError.new("Enable Banking session has expired. Please re-authorize.") + 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 + + # Phase 2: Check account setup status and collect sync statistics + sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + total_accounts = enable_banking_item.enable_banking_accounts.count + + linked_accounts = enable_banking_item.enable_banking_accounts.joins(:account_provider).joins(:account).merge(Account.visible) + unlinked_accounts = enable_banking_item.enable_banking_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + + sync_stats = { + total_accounts: total_accounts, + linked_accounts: linked_accounts.count, + unlinked_accounts: unlinked_accounts.count + } + + 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 accounts only + if linked_accounts.any? + sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text) + enable_banking_item.process_accounts + + # 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 + + if sync.respond_to?(:sync_stats) + sync.update!(sync_stats: sync_stats) + end + end + + def perform_post_sync + # no-op + end +end diff --git a/app/models/enable_banking_item/unlinking.rb b/app/models/enable_banking_item/unlinking.rb new file mode 100644 index 000000000..128b9493d --- /dev/null +++ b/app/models/enable_banking_item/unlinking.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module EnableBankingItem::Unlinking + # Concern that encapsulates unlinking logic for an Enable Banking item. + # Mirrors the LunchflowItem::Unlinking behavior. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this Enable Banking item and local accounts. + # - Detaches any AccountProvider links for each EnableBankingAccount + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-account result payload for observability + def unlink_all!(dry_run: false) + results = [] + + enable_banking_accounts.find_each do |eba| + links = AccountProvider.where(provider_type: "EnableBankingAccount", provider_id: eba.id).to_a + link_ids = links.map(&:id) + result = { + eba_id: eba.id, + name: eba.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + # Detach holdings for any provider links found + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + # Destroy all provider links + links.each do |ap| + ap.destroy! + end + end + rescue => e + Rails.logger.warn( + "EnableBankingItem Unlinker: failed to fully unlink EBA ##{eba.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + # Record error for observability; continue with other accounts + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/family.rb b/app/models/family.rb index b618f3785..deee144ee 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,5 +1,5 @@ class Family < ApplicationRecord - include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, Syncable, AutoTransferMatchable, Subscribeable + include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], diff --git a/app/models/family/enable_banking_connectable.rb b/app/models/family/enable_banking_connectable.rb new file mode 100644 index 000000000..6a735a6a4 --- /dev/null +++ b/app/models/family/enable_banking_connectable.rb @@ -0,0 +1,31 @@ +module Family::EnableBankingConnectable + extend ActiveSupport::Concern + + included do + has_many :enable_banking_items, dependent: :destroy + end + + def can_connect_enable_banking? + # Families can configure their own Enable Banking credentials + true + end + + def create_enable_banking_item!(country_code:, application_id:, client_certificate:, item_name: nil) + enable_banking_item = enable_banking_items.create!( + name: item_name || "Enable Banking Connection", + country_code: country_code, + application_id: application_id, + client_certificate: client_certificate + ) + + enable_banking_item + end + + def has_enable_banking_credentials? + enable_banking_items.where.not(client_certificate: nil).exists? + end + + def has_enable_banking_session? + enable_banking_items.where.not(session_id: nil).exists? + end +end diff --git a/app/models/provider/enable_banking.rb b/app/models/provider/enable_banking.rb new file mode 100644 index 000000000..f7d214dce --- /dev/null +++ b/app/models/provider/enable_banking.rb @@ -0,0 +1,242 @@ +require "cgi" + +class Provider::EnableBanking + include HTTParty + + BASE_URL = "https://api.enablebanking.com".freeze + + headers "User-Agent" => "Sure Finance Enable Banking Client" + default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + + attr_reader :application_id, :private_key + + def initialize(application_id:, client_certificate:) + @application_id = application_id + @private_key = extract_private_key(client_certificate) + end + + # Get list of available ASPSPs (banks) for a country + # @param country [String] ISO 3166-1 alpha-2 country code (e.g., "GB", "DE", "FR") + # @return [Array] List of ASPSPs + def get_aspsps(country:) + response = self.class.get( + "#{BASE_URL}/aspsps", + headers: auth_headers, + query: { country: country } + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise EnableBankingError.new("Exception during GET request: #{e.message}", :request_failed) + end + + # Initiate authorization flow - returns a redirect URL for the user + # @param aspsp_name [String] Name of the ASPSP from get_aspsps + # @param aspsp_country [String] Country code for the ASPSP + # @param redirect_url [String] URL to redirect user back to after auth + # @param state [String] Optional state parameter to pass through + # @param psu_type [String] "personal" or "business" + # @return [Hash] Contains :url and :authorization_id + def start_authorization(aspsp_name:, aspsp_country:, redirect_url:, state: nil, psu_type: "personal") + body = { + access: { + valid_until: (Time.current + 90.days).iso8601 + }, + aspsp: { + name: aspsp_name, + country: aspsp_country + }, + state: state, + redirect_url: redirect_url, + psu_type: psu_type + }.compact + + response = self.class.post( + "#{BASE_URL}/auth", + headers: auth_headers.merge("Content-Type" => "application/json"), + body: body.to_json + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise EnableBankingError.new("Exception during POST request: #{e.message}", :request_failed) + end + + # Exchange authorization code for a session + # @param code [String] The authorization code from the callback + # @return [Hash] Contains :session_id and :accounts + def create_session(code:) + body = { + code: code + } + + response = self.class.post( + "#{BASE_URL}/sessions", + headers: auth_headers.merge("Content-Type" => "application/json"), + body: body.to_json + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise EnableBankingError.new("Exception during POST request: #{e.message}", :request_failed) + end + + # Get session information + # @param session_id [String] The session ID + # @return [Hash] Session info including accounts + def get_session(session_id:) + response = self.class.get( + "#{BASE_URL}/sessions/#{session_id}", + headers: auth_headers + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise EnableBankingError.new("Exception during GET request: #{e.message}", :request_failed) + end + + # Delete a session (revoke consent) + # @param session_id [String] The session ID + def delete_session(session_id:) + response = self.class.delete( + "#{BASE_URL}/sessions/#{session_id}", + headers: auth_headers + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise EnableBankingError.new("Exception during DELETE request: #{e.message}", :request_failed) + end + + # Get account details + # @param account_id [String] The account ID (UID from Enable Banking) + # @return [Hash] Account details + def get_account_details(account_id:) + encoded_id = CGI.escape(account_id.to_s) + response = self.class.get( + "#{BASE_URL}/accounts/#{encoded_id}/details", + headers: auth_headers + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise EnableBankingError.new("Exception during GET request: #{e.message}", :request_failed) + end + + # Get account balances + # @param account_id [String] The account ID (UID from Enable Banking) + # @return [Hash] Balance information + def get_account_balances(account_id:) + encoded_id = CGI.escape(account_id.to_s) + response = self.class.get( + "#{BASE_URL}/accounts/#{encoded_id}/balances", + headers: auth_headers + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise EnableBankingError.new("Exception during GET request: #{e.message}", :request_failed) + end + + # Get account transactions + # @param account_id [String] The account ID (UID from Enable Banking) + # @param date_from [Date, nil] Start date for transactions + # @param date_to [Date, nil] End date for transactions + # @param continuation_key [String, nil] For pagination + # @return [Hash] Transactions and continuation_key for pagination + def get_account_transactions(account_id:, date_from: nil, date_to: nil, continuation_key: nil) + encoded_id = CGI.escape(account_id.to_s) + query_params = {} + query_params[:date_from] = date_from.to_date.iso8601 if date_from + query_params[:date_to] = date_to.to_date.iso8601 if date_to + query_params[:continuation_key] = continuation_key if continuation_key + + response = self.class.get( + "#{BASE_URL}/accounts/#{encoded_id}/transactions", + headers: auth_headers, + query: query_params.presence + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise EnableBankingError.new("Exception during GET request: #{e.message}", :request_failed) + end + + private + + def extract_private_key(certificate_pem) + # Extract private key from PEM certificate + OpenSSL::PKey::RSA.new(certificate_pem) + rescue OpenSSL::PKey::RSAError => e + Rails.logger.error "Enable Banking: Failed to parse private key: #{e.message}" + raise EnableBankingError.new("Invalid private key in certificate: #{e.message}", :invalid_certificate) + end + + def generate_jwt + now = Time.current.to_i + + header = { + typ: "JWT", + alg: "RS256", + kid: application_id + } + + payload = { + iss: "enablebanking.com", + aud: "api.enablebanking.com", + iat: now, + exp: now + 3600 # 1 hour expiry + } + + # Encode JWT + JWT.encode(payload, private_key, "RS256", header) + end + + def auth_headers + { + "Authorization" => "Bearer #{generate_jwt}", + "Accept" => "application/json" + } + end + + def handle_response(response) + case response.code + when 200, 201 + parse_response_body(response) + when 204 + {} + when 400 + raise EnableBankingError.new("Bad request to Enable Banking API: #{response.body}", :bad_request) + when 401 + raise EnableBankingError.new("Invalid credentials or expired JWT", :unauthorized) + when 403 + raise EnableBankingError.new("Access forbidden - check your application permissions", :access_forbidden) + when 404 + raise EnableBankingError.new("Resource not found", :not_found) + when 422 + raise EnableBankingError.new("Validation error from Enable Banking API: #{response.body}", :validation_error) + when 429 + raise EnableBankingError.new("Rate limit exceeded. Please try again later.", :rate_limited) + else + raise EnableBankingError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed) + end + end + + def parse_response_body(response) + return {} if response.body.blank? + + JSON.parse(response.body, symbolize_names: true) + rescue JSON::ParserError => e + Rails.logger.error "Enable Banking API: Failed to parse response: #{e.message}" + raise EnableBankingError.new("Failed to parse API response", :parse_error) + end + + class EnableBankingError < StandardError + attr_reader :error_type + + def initialize(message, error_type = :unknown) + super(message) + @error_type = error_type + end + end +end diff --git a/app/models/provider/enable_banking_adapter.rb b/app/models/provider/enable_banking_adapter.rb new file mode 100644 index 000000000..379978bd8 --- /dev/null +++ b/app/models/provider/enable_banking_adapter.rb @@ -0,0 +1,64 @@ +class Provider::EnableBankingAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("EnableBankingAccount", self) + + def provider_name + "enable_banking" + end + + # Build an EnableBanking provider instance with family-specific credentials + # @param family [Family] The family to get credentials for (required) + # @return [Provider::EnableBanking, nil] Returns nil if credentials are not configured + def self.build_provider(family: nil) + return nil unless family.present? + + # Get family-specific credentials + enable_banking_item = family.enable_banking_items.where.not(client_certificate: nil).first + return nil unless enable_banking_item&.credentials_configured? + + Provider::EnableBanking.new( + application_id: enable_banking_item.application_id, + client_certificate: enable_banking_item.client_certificate + ) + end + + def sync_path + Rails.application.routes.url_helpers.sync_enable_banking_item_path(item) + end + + def item + provider_account.enable_banking_item + end + + def can_delete_holdings? + false + end + + def institution_domain + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["domain"] + end + + def institution_name + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["name"] || metadata["aspsp_name"] || item&.aspsp_name + end + + def institution_url + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["url"] || item&.institution_url + end + + def institution_color + item&.institution_color + end +end diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index add803979..e6a7341d3 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -1,5 +1,5 @@ class ProviderMerchant < Merchant - enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index c8adc0bf0..f9447c314 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -21,7 +21,7 @@ -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? %> <%= render "empty" %> <% else %>
@@ -37,6 +37,10 @@ <%= render @lunchflow_items.sort_by(&:created_at) %> <% end %> + <% if @enable_banking_items.any? %> + <%= render @enable_banking_items.sort_by(&:created_at) %> + <% end %> + <% if @manual_accounts.any? %>
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> @@ -46,4 +50,3 @@ <% end %>
<% end %> - diff --git a/app/views/enable_banking_items/_enable_banking_item.html.erb b/app/views/enable_banking_items/_enable_banking_item.html.erb new file mode 100644 index 000000000..05c3d7328 --- /dev/null +++ b/app/views/enable_banking_items/_enable_banking_item.html.erb @@ -0,0 +1,109 @@ +<%# locals: (enable_banking_item:) %> + +<%= tag.div id: dom_id(enable_banking_item) do %> +
+ +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+ <% if enable_banking_item.logo.attached? %> + <%= image_tag enable_banking_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> + <% else %> +
+ <%= tag.p enable_banking_item.institution_display_name.first.upcase, class: "text-success text-xs font-medium" %> +
+ <% end %> +
+ +
+
+ <%= tag.p enable_banking_item.institution_display_name, class: "font-medium text-primary" %> + <% if enable_banking_item.scheduled_for_deletion? %> +

Deletion in progress

+ <% end %> +
+

Enable Banking

+ <% if enable_banking_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span "Syncing..." %> +
+ <% elsif enable_banking_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span "Re-authorization required" %> +
+ <% else %> +

+ <% if enable_banking_item.last_synced_at %> + Last synced <%= time_ago_in_words(enable_banking_item.last_synced_at) %> ago + <% if enable_banking_item.sync_status_summary %> + · <%= enable_banking_item.sync_status_summary %> + <% end %> + <% else %> + Never synced + <% end %> +

+ <% end %> +
+
+ +
+ <% if enable_banking_item.requires_update? %> + <%= button_to reauthorize_enable_banking_item_path(enable_banking_item), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-white bg-warning hover:opacity-90 transition-colors", + data: { turbo: false } do %> + <%= icon "refresh-cw", size: "sm" %> + Re-authorize + <% end %> + <% elsif Rails.env.development? %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_enable_banking_item_path(enable_banking_item) + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: "Delete", + icon: "trash-2", + href: enable_banking_item_path(enable_banking_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(enable_banking_item.institution_display_name, high_severity: true) + ) %> + <% end %> +
+
+ + <% unless enable_banking_item.scheduled_for_deletion? %> +
+ <% if enable_banking_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: enable_banking_item.accounts %> + <% end %> + + <% if enable_banking_item.unlinked_accounts_count > 0 %> +
+

Setup needed

+

<%= pluralize(enable_banking_item.unlinked_accounts_count, "account") %> imported from Enable Banking need to be set up

+ <%= render DS::Link.new( + text: "Set up accounts", + icon: "settings", + variant: "primary", + href: setup_accounts_enable_banking_item_path(enable_banking_item), + frame: :modal + ) %> +
+ <% elsif enable_banking_item.accounts.empty? && enable_banking_item.enable_banking_accounts.empty? %> +
+

No accounts found

+

No accounts were found from Enable Banking. Try syncing again.

+
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/enable_banking_items/_subtype_select.html.erb b/app/views/enable_banking_items/_subtype_select.html.erb new file mode 100644 index 000000000..a9bab3848 --- /dev/null +++ b/app/views/enable_banking_items/_subtype_select.html.erb @@ -0,0 +1,14 @@ + diff --git a/app/views/enable_banking_items/select_bank.html.erb b/app/views/enable_banking_items/select_bank.html.erb new file mode 100644 index 000000000..561639dd8 --- /dev/null +++ b/app/views/enable_banking_items/select_bank.html.erb @@ -0,0 +1,57 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title", default: "Select Your Bank")) %> + + <% dialog.with_body do %> +
+

+ <%= t(".description", default: "Choose the bank you want to connect to your account.") %> +

+ + <% if @error_message.present? %> +
+ <%= @error_message %> +
+ <% end %> + + <% if @aspsps.present? %> +
+ <% @aspsps.each do |aspsp| %> + <%= button_to authorize_enable_banking_item_path(@enable_banking_item), + method: :post, + params: { aspsp_name: aspsp[:name], new_connection: @new_connection }, + class: "w-full flex items-center gap-4 p-3 rounded-lg border border-primary bg-container hover:bg-subtle transition-colors text-left", + data: { turbo: false } do %> + <% if aspsp[:logo].present? %> + <%= aspsp[:name] %> + <% else %> +
+ <%= icon "building-bank", class: "w-5 h-5 text-gray-400" %> +
+ <% end %> +
+

<%= aspsp[:name] %>

+ <% if aspsp[:bic].present? %> +

BIC: <%= aspsp[:bic] %>

+ <% end %> +
+ <%= icon "chevron-right", class: "w-5 h-5 text-secondary" %> + <% end %> + <% end %> +
+ <% else %> +
+

<%= t(".no_banks", default: "No banks available for this country.") %>

+

<%= t(".check_country", default: "Please check your country code setting.") %>

+
+ <% end %> + +
+ <%= link_to t(".cancel", default: "Cancel"), settings_providers_path, + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover", + data: { turbo_frame: "_top", action: "DS--dialog#close" } %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/enable_banking_items/setup_accounts.html.erb b/app/views/enable_banking_items/setup_accounts.html.erb new file mode 100644 index 000000000..2db41d72d --- /dev/null +++ b/app/views/enable_banking_items/setup_accounts.html.erb @@ -0,0 +1,118 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: "Set Up Your Enable Banking Accounts") do %> +
+ <%= icon "building-2", class: "text-primary" %> + Choose the correct account types for your imported accounts +
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: complete_account_setup_enable_banking_item_path(@enable_banking_item), + method: :post, + local: true, + data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: "Creating Accounts...", + turbo_frame: "_top" + }, + class: "space-y-6" do |form| %> + +
+
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

+ Choose the correct account type for each Enable Banking account: +

+
    + <% @account_type_options.reject { |_, type| type == "skip" }.each do |label, _| %> +
  • <%= label %>
  • + <% end %> +
+
+
+
+ + +
+
+ <%= icon "calendar", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

+ Historical Data Range: +

+ <%= form.date_field :sync_start_date, + label: "Start syncing transactions from:", + value: @enable_banking_item.sync_start_date || 3.months.ago.to_date, + min: 1.year.ago.to_date, + max: Date.current, + class: "w-full max-w-xs rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary", + help_text: "Select how far back you want to sync transaction history. Maximum 1 year of history available." %> +
+
+
+ + <% @enable_banking_accounts.each do |enable_banking_account| %> +
+
+
+

+ <%= enable_banking_account.name %> + <% if enable_banking_account.iban.present? %> + • <%= enable_banking_account.iban.last(4) %> + <% end %> +

+
+ <% if enable_banking_account.account_type_display.present? %> +

<%= enable_banking_account.account_type_display %>

+ <% end %> + <% if enable_banking_account.current_balance.present? %> +

Balance: <%= number_to_currency(enable_banking_account.current_balance, unit: enable_banking_account.currency) %>

+ <% end %> +
+
+
+ +
+
+ <%= label_tag "account_types[#{enable_banking_account.id}]", "Account Type:", + class: "block text-sm font-medium text-primary mb-2" %> + <%= select_tag "account_types[#{enable_banking_account.id}]", + options_for_select(@account_type_options, "skip"), + { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full", + data: { + action: "change->account-type-selector#updateSubtype" + } } %> +
+ + +
+ <% @subtype_options.each do |account_type, subtype_config| %> + <%= render "enable_banking_items/subtype_select", account_type: account_type, subtype_config: subtype_config, enable_banking_account: enable_banking_account %> + <% end %> +
+
+
+ <% end %> +
+ +
+ <%= render DS::Button.new( + text: "Create Accounts", + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + data: { loading_button_target: "button" } + ) %> + <%= render DS::Link.new( + text: "Cancel", + variant: "secondary", + href: accounts_path + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/settings/bank_sync/_provider_link.html.erb b/app/views/settings/bank_sync/_provider_link.html.erb index 41a69d9ff..56064246d 100644 --- a/app/views/settings/bank_sync/_provider_link.html.erb +++ b/app/views/settings/bank_sync/_provider_link.html.erb @@ -4,7 +4,8 @@ <% provider_colors = { "Lunch Flow" => "#6471eb", "Plaid" => "#4da568", - "SimpleFin" => "#e99537" + "SimpleFin" => "#e99537", + "Enable Banking" => "#6471eb" } %> <% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %> diff --git a/app/views/settings/providers/_enable_banking_panel.html.erb b/app/views/settings/providers/_enable_banking_panel.html.erb new file mode 100644 index 000000000..3fe8d1203 --- /dev/null +++ b/app/views/settings/providers/_enable_banking_panel.html.erb @@ -0,0 +1,188 @@ +
+
+

Setup instructions:

+
    +
  1. Visit your Enable Banking developer account to get your credentials
  2. +
  3. Select your country code from the dropdown below
  4. +
  5. Enter your Application ID and paste your Client Certificate (including the private key)
  6. +
  7. Click Save Configuration, then use "Add Connection" to link your bank
  8. +
+ +

Field descriptions:

+
    +
  • Country Code: ISO 3166-1 alpha-2 country code (e.g., GB, DE, FR) - determines available banks
  • +
  • Application ID: The ID generated in your Enable Banking developer account
  • +
  • Client Certificate: The certificate generated when you created your application (must include the private key)
  • +
+
+ + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+

<%= error_msg %>

+
+ <% end %> + + <% + enable_banking_item = Current.family.enable_banking_items.first_or_initialize(name: "Enable Banking Connection") + is_new_record = enable_banking_item.new_record? + # Check if there are any authenticated connections (have session_id) + has_authenticated_connections = Current.family.enable_banking_items.where.not(session_id: nil).exists? + %> + + <%= styled_form_with model: enable_banking_item, + url: is_new_record ? enable_banking_items_path : enable_banking_item_path(enable_banking_item), + scope: :enable_banking_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <% if has_authenticated_connections && !is_new_record %> +
+

Configuration locked

+

Credentials cannot be changed while you have active bank connections. Remove all connections first to update credentials.

+
+ <% end %> + +
+ <%= form.select :country_code, + options_for_select([ + ["Austria (AT)", "AT"], + ["Belgium (BE)", "BE"], + ["Bulgaria (BG)", "BG"], + ["Croatia (HR)", "HR"], + ["Cyprus (CY)", "CY"], + ["Czech Republic (CZ)", "CZ"], + ["Denmark (DK)", "DK"], + ["Estonia (EE)", "EE"], + ["Finland (FI)", "FI"], + ["France (FR)", "FR"], + ["Germany (DE)", "DE"], + ["Greece (GR)", "GR"], + ["Hungary (HU)", "HU"], + ["Iceland (IS)", "IS"], + ["Ireland (IE)", "IE"], + ["Italy (IT)", "IT"], + ["Latvia (LV)", "LV"], + ["Liechtenstein (LI)", "LI"], + ["Lithuania (LT)", "LT"], + ["Luxembourg (LU)", "LU"], + ["Malta (MT)", "MT"], + ["Netherlands (NL)", "NL"], + ["Norway (NO)", "NO"], + ["Poland (PL)", "PL"], + ["Portugal (PT)", "PT"], + ["Romania (RO)", "RO"], + ["Slovakia (SK)", "SK"], + ["Slovenia (SI)", "SI"], + ["Spain (ES)", "ES"], + ["Sweden (SE)", "SE"], + ["United Kingdom (GB)", "GB"] + ], enable_banking_item.country_code), + { label: true, include_blank: "Select country..." }, + { label: "Country", class: "form-field__input", disabled: has_authenticated_connections && !is_new_record } %> + + <%= form.text_field :application_id, + label: "Application ID", + placeholder: is_new_record ? "Enter application ID" : "Enter new ID to update", + value: enable_banking_item.application_id, + disabled: has_authenticated_connections && !is_new_record %> +
+ + <%= form.text_area :client_certificate, + label: "Client Certificate (with Private Key)", + placeholder: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + rows: 6, + class: "form-field__input font-mono text-xs", + disabled: has_authenticated_connections && !is_new_record %> + + <% unless has_authenticated_connections && !is_new_record %> +
+ <%= form.submit is_new_record ? "Save Configuration" : "Update Configuration", + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %> +
+ <% end %> + <% end %> + + <% items = local_assigns[:enable_banking_items] || @enable_banking_items || Current.family.enable_banking_items.where.not(client_certificate: nil) %> + <% if items&.any? %> + <% + # Find the first item with valid session to use for "Add Connection" button + item_for_new_connection = items.find(&:session_valid?) + # Check if any item needs initial connection (configured but no session yet) + item_needing_connection = items.find { |i| !i.session_valid? && !i.session_expired? } + %> +
+ <% items.each do |item| %> +
+
+ <% if item.session_valid? %> +
+
+

<%= item.aspsp_name || "Connected Bank" %>

+

+ Session expires: <%= item.session_expires_at&.strftime("%b %d, %Y") || "Unknown" %> +

+
+ <% elsif item.session_expired? %> +
+
+

<%= item.aspsp_name || "Connection" %>

+

Session expired - re-authorization required

+
+ <% else %> +
+
+

Configured

+

Ready to connect a bank

+
+ <% end %> +
+ +
+ <% if item.session_valid? %> + <%= button_to sync_enable_banking_item_path(item), + method: :post, + class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-primary bg-container border border-primary hover:bg-gray-50 transition-colors", + data: { turbo: false } do %> + Sync + <% end %> + <% elsif item.session_expired? %> + <%= button_to reauthorize_enable_banking_item_path(item), + method: :post, + class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-warning hover:opacity-90 transition-colors", + data: { turbo: false } do %> + Re-authorize + <% end %> + <% else %> + <%= link_to select_bank_enable_banking_item_path(item), + class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-gray-900 hover:bg-gray-800 transition-colors", + data: { turbo_frame: "modal" } do %> + Connect Bank + <% end %> + <% end %> + + <%= button_to enable_banking_item_path(item), + method: :delete, + class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors", + data: { turbo_confirm: "Are you sure you want to remove this connection?" } do %> + Remove + <% end %> +
+
+ <% end %> + + <%# Add Connection button below the list - only show if we have a valid session to copy credentials from %> + <% if item_for_new_connection %> +
+ <%= button_to new_connection_enable_banking_item_path(item_for_new_connection), + method: :post, + class: "inline-flex items-center gap-2 justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 transition-colors", + data: { turbo_frame: "modal" } do %> + <%= icon "plus", size: "sm" %> + Add Connection + <% end %> +
+ <% end %> +
+ <% end %> +
diff --git a/app/views/settings/providers/_lunchflow_panel.html.erb b/app/views/settings/providers/_lunchflow_panel.html.erb index eb7199668..35d24c53d 100644 --- a/app/views/settings/providers/_lunchflow_panel.html.erb +++ b/app/views/settings/providers/_lunchflow_panel.html.erb @@ -16,8 +16,8 @@ <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
- <%= error_msg %> +
+

<%= error_msg %>

<% end %> diff --git a/app/views/settings/providers/_simplefin_panel.html.erb b/app/views/settings/providers/_simplefin_panel.html.erb index db36085d8..1e50a611c 100644 --- a/app/views/settings/providers/_simplefin_panel.html.erb +++ b/app/views/settings/providers/_simplefin_panel.html.erb @@ -14,8 +14,8 @@
<% if defined?(@error_message) && @error_message.present? %> -
- <%= @error_message %> +
+

<%= @error_message %>

<% end %> diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index ccd19cb6b..4f84027ad 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -26,4 +26,10 @@ <% end %> + + <%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %> + + <%= render "settings/providers/enable_banking_panel" %> + + <% end %>
diff --git a/app/views/shared/notifications/_alert.html.erb b/app/views/shared/notifications/_alert.html.erb index d53793df0..0cd8434aa 100644 --- a/app/views/shared/notifications/_alert.html.erb +++ b/app/views/shared/notifications/_alert.html.erb @@ -8,7 +8,7 @@
- <%= tag.p message, class: "text-primary text-sm font-medium" %> + <%= tag.p message, class: "text-primary text-sm font-medium line-clamp-3 min-w-0", title: message %>
<%= icon "x", data: { action: "click->element-removal#remove" }, class: "cursor-pointer" %> diff --git a/app/views/shared/notifications/_notice.html.erb b/app/views/shared/notifications/_notice.html.erb index 5081cfc98..a715f674e 100644 --- a/app/views/shared/notifications/_notice.html.erb +++ b/app/views/shared/notifications/_notice.html.erb @@ -13,10 +13,10 @@
- <%= tag.p message, class: "text-primary text-sm font-medium" %> + <%= tag.p message, class: "text-primary text-sm font-medium line-clamp-3 min-w-0", title: message %> <% if description %> - <%= tag.p description, class: "text-secondary text-sm" %> + <%= tag.p description, class: "text-secondary text-sm line-clamp-3", title: description %> <% end %>
diff --git a/config/routes.rb b/config/routes.rb index 740277b35..c854eded2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,21 @@ require "sidekiq/web" require "sidekiq/cron/web" Rails.application.routes.draw do + resources :enable_banking_items, only: [ :create, :update, :destroy ] do + collection do + get :callback + post :link_accounts + end + member do + post :sync + get :select_bank + post :authorize + post :reauthorize + get :setup_accounts + post :complete_account_setup + post :new_connection + end + end use_doorkeeper # MFA routes resource :mfa, controller: "mfa", only: [ :new, :create ] do diff --git a/db/migrate/20251126094446_create_enable_banking_items_and_accounts.rb b/db/migrate/20251126094446_create_enable_banking_items_and_accounts.rb new file mode 100644 index 000000000..b1ded634a --- /dev/null +++ b/db/migrate/20251126094446_create_enable_banking_items_and_accounts.rb @@ -0,0 +1,73 @@ +class CreateEnableBankingItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + # Create provider items table (stores per-family connection credentials) + create_table :enable_banking_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + + # Institution metadata + t.string :institution_id + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + + # Status and lifecycle + t.string :status, default: "good" + t.boolean :scheduled_for_deletion, default: false + t.boolean :pending_account_setup, default: false + + # Sync settings + t.datetime :sync_start_date + + # Raw data storage + t.jsonb :raw_payload + t.jsonb :raw_institution_payload + + # Provider-specific credential fields + t.string :country_code + t.string :application_id + t.text :client_certificate + + # OAuth session fields + t.string :session_id + t.datetime :session_expires_at + t.string :aspsp_name # Bank/ASPSP name + t.string :aspsp_id # Bank/ASPSP identifier + + # Authorization flow fields (temporary, cleared after session created) + t.string :authorization_id + + t.timestamps + end + + add_index :enable_banking_items, :status + + # Create provider accounts table (stores individual account data from provider) + create_table :enable_banking_accounts, id: :uuid do |t| + t.references :enable_banking_item, null: false, foreign_key: true, type: :uuid + + # Account identification + t.string :name + t.string :account_id + + # Account details + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :provider + t.string :iban + t.string :uid # Enable Banking unique identifier + + # Metadata and raw data + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + + t.timestamps + end + + add_index :enable_banking_accounts, :account_id + end +end diff --git a/db/schema.rb b/db/schema.rb index dd63da15c..bc46c3327 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_11_21_140453) do +ActiveRecord::Schema[7.2].define(version: 2025_11_26_094446) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -233,6 +233,54 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_21_140453) do t.string "subtype" end + create_table "enable_banking_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "enable_banking_item_id", null: false + t.string "name" + t.string "account_id" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "provider" + t.string "iban" + t.string "uid" + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_enable_banking_accounts_on_account_id" + t.index ["enable_banking_item_id"], name: "index_enable_banking_accounts_on_enable_banking_item_id" + end + + create_table "enable_banking_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "country_code" + t.string "application_id" + t.text "client_certificate" + t.string "session_id" + t.datetime "session_expires_at" + t.string "aspsp_name" + t.string "aspsp_id" + t.string "authorization_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_enable_banking_items_on_family_id" + t.index ["status"], name: "index_enable_banking_items_on_status" + end + create_table "entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false t.string "entryable_type" @@ -1020,6 +1068,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_21_140453) do add_foreign_key "budgets", "families" add_foreign_key "categories", "families" add_foreign_key "chats", "users" + add_foreign_key "enable_banking_accounts", "enable_banking_items" + add_foreign_key "enable_banking_items", "families" add_foreign_key "entries", "accounts", on_delete: :cascade add_foreign_key "entries", "imports" add_foreign_key "family_exports", "families"