Initial enable banking implementation (#382)

* Initial enable banking implementation

* Handle multiple connections

* Amount fixes

* Account type mapping

* Add option to skip accounts

* Update schema.rb

* Transaction fixes

* Provider fixes

* FIX account identifier

* FIX support unlinking

* UI style fixes

* FIX safe redirect and brakeman issue

* FIX

- pagination max fix
- wrap crud in transaction logic

* FIX api uid access

- The Enable Banking API expects the UUID (uid from the API response) to fetch balances/transactions, not the identification_hash

* FIX add new connection

* FIX erb code

* Alert/notice box overflow protection

* Give alert/notification boxes room to grow (3 lines max)

* Add "Enable Banking (beta)" to `/settings/bank_sync`

* Make Enable Banking section collapsible like all others

* Add callback hint to error message

---------

Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
soky srm
2025-11-29 13:31:08 +01:00
committed by GitHub
parent ba266986d4
commit 4a29d030af
36 changed files with 2642 additions and 15 deletions

View File

@@ -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 = {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
</div>
</header>
<% 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 %>
<div class="space-y-2">
@@ -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? %>
<div id="manual-accounts">
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>
@@ -46,4 +50,3 @@
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,109 @@
<%# locals: (enable_banking_item:) %>
<%= tag.div id: dom_id(enable_banking_item) do %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<div class="flex items-center justify-center h-8 w-8 bg-success/10 rounded-full">
<% if enable_banking_item.logo.attached? %>
<%= image_tag enable_banking_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
<% else %>
<div class="flex items-center justify-center">
<%= tag.p enable_banking_item.institution_display_name.first.upcase, class: "text-success text-xs font-medium" %>
</div>
<% end %>
</div>
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<%= tag.p enable_banking_item.institution_display_name, class: "font-medium text-primary" %>
<% if enable_banking_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse">Deletion in progress</p>
<% end %>
</div>
<p class="text-xs text-secondary">Enable Banking</p>
<% if enable_banking_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span "Syncing..." %>
</div>
<% elsif enable_banking_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span "Re-authorization required" %>
</div>
<% else %>
<p class="text-secondary">
<% 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 %>
</p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<% 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 %>
</div>
</summary>
<% unless enable_banking_item.scheduled_for_deletion? %>
<div class="space-y-4 mt-4">
<% 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 %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm">Setup needed</p>
<p class="text-secondary text-sm"><%= pluralize(enable_banking_item.unlinked_accounts_count, "account") %> imported from Enable Banking need to be set up</p>
<%= render DS::Link.new(
text: "Set up accounts",
icon: "settings",
variant: "primary",
href: setup_accounts_enable_banking_item_path(enable_banking_item),
frame: :modal
) %>
</div>
<% elsif enable_banking_item.accounts.empty? && enable_banking_item.enable_banking_accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm">No accounts found</p>
<p class="text-secondary text-sm">No accounts were found from Enable Banking. Try syncing again.</p>
</div>
<% end %>
</div>
<% end %>
</details>
<% end %>

View File

@@ -0,0 +1,14 @@
<div class="subtype-select" data-type="<%= account_type %>" style="display: none;">
<% if subtype_config[:options].present? %>
<%= label_tag "account_subtypes[#{enable_banking_account.id}]", subtype_config[:label],
class: "block text-sm font-medium text-primary mb-2" %>
<% selected_value = account_type == "Depository" ?
(enable_banking_account.name.downcase.include?("checking") ? "checking" :
enable_banking_account.name.downcase.include?("savings") ? "savings" : "") : "" %>
<%= select_tag "account_subtypes[#{enable_banking_account.id}]",
options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value),
{ 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" } %>
<% else %>
<p class="text-sm text-secondary"><%= subtype_config[:message] %></p>
<% end %>
</div>

View File

@@ -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 %>
<div class="space-y-4">
<p class="text-sm text-secondary">
<%= t(".description", default: "Choose the bank you want to connect to your account.") %>
</p>
<% if @error_message.present? %>
<div class="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
<%= @error_message %>
</div>
<% end %>
<% if @aspsps.present? %>
<div class="space-y-2 max-h-80 overflow-y-auto">
<% @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? %>
<img src="<%= aspsp[:logo] %>" alt="<%= aspsp[:name] %>" class="w-10 h-10 rounded object-contain">
<% else %>
<div class="w-10 h-10 rounded bg-gray-100 flex items-center justify-center">
<%= icon "building-bank", class: "w-5 h-5 text-gray-400" %>
</div>
<% end %>
<div class="flex-1">
<p class="font-medium text-sm text-primary"><%= aspsp[:name] %></p>
<% if aspsp[:bic].present? %>
<p class="text-xs text-secondary">BIC: <%= aspsp[:bic] %></p>
<% end %>
</div>
<%= icon "chevron-right", class: "w-5 h-5 text-secondary" %>
<% end %>
<% end %>
</div>
<% else %>
<div class="text-center py-8">
<p class="text-secondary"><%= t(".no_banks", default: "No banks available for this country.") %></p>
<p class="text-sm text-secondary mt-2"><%= t(".check_country", default: "Please check your country code setting.") %></p>
</div>
<% end %>
<div class="flex justify-end pt-4">
<%= 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" } %>
</div>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,118 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Set Up Your Enable Banking Accounts") do %>
<div class="flex items-center gap-2">
<%= icon "building-2", class: "text-primary" %>
<span class="text-primary">Choose the correct account types for your imported accounts</span>
</div>
<% 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| %>
<div class="space-y-4">
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm text-primary mb-2">
<strong>Choose the correct account type for each Enable Banking account:</strong>
</p>
<ul class="text-xs text-secondary space-y-1 list-disc list-inside">
<% @account_type_options.reject { |_, type| type == "skip" }.each do |label, _| %>
<li><strong><%= label %></strong></li>
<% end %>
</ul>
</div>
</div>
</div>
<!-- Sync Date Range Selection -->
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
<%= icon "calendar", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div class="flex-1">
<p class="text-sm text-primary mb-3">
<strong>Historical Data Range:</strong>
</p>
<%= 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." %>
</div>
</div>
</div>
<% @enable_banking_accounts.each do |enable_banking_account| %>
<div class="border border-primary rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="font-medium text-primary">
<%= enable_banking_account.name %>
<% if enable_banking_account.iban.present? %>
<span class="text-secondary">• <%= enable_banking_account.iban.last(4) %></span>
<% end %>
</h3>
<div class="text-sm text-secondary space-y-0.5">
<% if enable_banking_account.account_type_display.present? %>
<p><%= enable_banking_account.account_type_display %></p>
<% end %>
<% if enable_banking_account.current_balance.present? %>
<p>Balance: <%= number_to_currency(enable_banking_account.current_balance, unit: enable_banking_account.currency) %></p>
<% end %>
</div>
</div>
</div>
<div class="space-y-3" data-controller="account-type-selector" data-account-type-selector-account-id-value="<%= enable_banking_account.id %>">
<div>
<%= 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"
} } %>
</div>
<!-- Subtype dropdowns (shown/hidden based on account type) -->
<div data-account-type-selector-target="subtypeContainer">
<% @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 %>
</div>
</div>
</div>
<% end %>
</div>
<div class="flex gap-3">
<%= 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
) %>
</div>
<% end %>
<% end %>
<% end %>

View File

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

View File

@@ -0,0 +1,188 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium">Setup instructions:</p>
<ol>
<li>Visit your <a href="https://enablebanking.com" target="_blank" rel="noopener noreferrer" class="link">Enable Banking</a> developer account to get your credentials</li>
<li>Select your country code from the dropdown below</li>
<li>Enter your Application ID and paste your Client Certificate (including the private key)</li>
<li>Click Save Configuration, then use "Add Connection" to link your bank</li>
</ol>
<p class="text-primary font-medium">Field descriptions:</p>
<ul>
<li><strong>Country Code:</strong> ISO 3166-1 alpha-2 country code (e.g., GB, DE, FR) - determines available banks</li>
<li><strong>Application ID:</strong> The ID generated in your Enable Banking developer account</li>
<li><strong>Client Certificate:</strong> The certificate generated when you created your application (must include the private key)</li>
</ul>
</div>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<% 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 %>
<div class="p-3 rounded-md bg-warning/10 text-warning text-sm">
<p class="font-medium">Configuration locked</p>
<p class="text-xs mt-1">Credentials cannot be changed while you have active bank connections. Remove all connections first to update credentials.</p>
</div>
<% end %>
<div class="grid grid-cols-2 gap-3">
<%= 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 %>
</div>
<%= 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 %>
<div class="flex justify-end">
<%= 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" %>
</div>
<% 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? }
%>
<div class="border-t border-primary pt-4 space-y-3">
<% items.each do |item| %>
<div class="flex items-center justify-between p-3 rounded-lg bg-container border border-primary">
<div class="flex items-center gap-3">
<% if item.session_valid? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || "Connected Bank" %></p>
<p class="text-xs text-secondary">
Session expires: <%= item.session_expires_at&.strftime("%b %d, %Y") || "Unknown" %>
</p>
</div>
<% elsif item.session_expired? %>
<div class="w-2 h-2 bg-warning rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || "Connection" %></p>
<p class="text-xs text-destructive">Session expired - re-authorization required</p>
</div>
<% else %>
<div class="w-2 h-2 bg-secondary rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary">Configured</p>
<p class="text-xs text-secondary">Ready to connect a bank</p>
</div>
<% end %>
</div>
<div class="flex items-center gap-2">
<% 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 %>
</div>
</div>
<% end %>
<%# Add Connection button below the list - only show if we have a valid session to copy credentials from %>
<% if item_for_new_connection %>
<div class="flex justify-center pt-2">
<%= 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 %>
</div>
<% end %>
</div>
<% end %>
</div>

View File

@@ -16,8 +16,8 @@
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm">
<%= error_msg %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<% end %>

View File

@@ -14,8 +14,8 @@
</div>
<% if defined?(@error_message) && @error_message.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm">
<%= @error_message %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= @error_message %>"><%= @error_message %></p>
</div>
<% end %>

View File

@@ -26,4 +26,10 @@
</turbo-frame>
<% end %>
<%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %>
<turbo-frame id="enable_banking-providers-panel">
<%= render "settings/providers/enable_banking_panel" %>
</turbo-frame>
<% end %>
</div>

View File

@@ -8,7 +8,7 @@
</div>
</div>
<%= 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 %>
<div class="ml-auto">
<%= icon "x", data: { action: "click->element-removal#remove" }, class: "cursor-pointer" %>

View File

@@ -13,10 +13,10 @@
<div class="space-y-4">
<div class="space-y-1">
<%= 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 %>
</div>
</div>

View File

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

View File

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

52
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 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"