mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
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:
@@ -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 = {}
|
||||
|
||||
469
app/controllers/enable_banking_items_controller.rb
Normal file
469
app/controllers/enable_banking_items_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
130
app/models/enable_banking_account.rb
Normal file
130
app/models/enable_banking_account.rb
Normal 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
|
||||
69
app/models/enable_banking_account/processor.rb
Normal file
69
app/models/enable_banking_account/processor.rb
Normal 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
|
||||
66
app/models/enable_banking_account/transactions/processor.rb
Normal file
66
app/models/enable_banking_account/transactions/processor.rb
Normal 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
|
||||
196
app/models/enable_banking_entry/processor.rb
Normal file
196
app/models/enable_banking_entry/processor.rb
Normal 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
|
||||
284
app/models/enable_banking_item.rb
Normal file
284
app/models/enable_banking_item.rb
Normal 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
|
||||
251
app/models/enable_banking_item/importer.rb
Normal file
251
app/models/enable_banking_item/importer.rb
Normal 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
|
||||
12
app/models/enable_banking_item/provided.rb
Normal file
12
app/models/enable_banking_item/provided.rb
Normal 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
|
||||
25
app/models/enable_banking_item/sync_complete_event.rb
Normal file
25
app/models/enable_banking_item/sync_complete_event.rb
Normal 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
|
||||
62
app/models/enable_banking_item/syncer.rb
Normal file
62
app/models/enable_banking_item/syncer.rb
Normal 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
|
||||
50
app/models/enable_banking_item/unlinking.rb
Normal file
50
app/models/enable_banking_item/unlinking.rb
Normal 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
|
||||
@@ -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" ],
|
||||
|
||||
31
app/models/family/enable_banking_connectable.rb
Normal file
31
app/models/family/enable_banking_connectable.rb
Normal 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
|
||||
242
app/models/provider/enable_banking.rb
Normal file
242
app/models/provider/enable_banking.rb
Normal 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
|
||||
64
app/models/provider/enable_banking_adapter.rb
Normal file
64
app/models/provider/enable_banking_adapter.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
109
app/views/enable_banking_items/_enable_banking_item.html.erb
Normal file
109
app/views/enable_banking_items/_enable_banking_item.html.erb
Normal 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 %>
|
||||
14
app/views/enable_banking_items/_subtype_select.html.erb
Normal file
14
app/views/enable_banking_items/_subtype_select.html.erb
Normal 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>
|
||||
57
app/views/enable_banking_items/select_bank.html.erb
Normal file
57
app/views/enable_banking_items/select_bank.html.erb
Normal 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 %>
|
||||
118
app/views/enable_banking_items/setup_accounts.html.erb
Normal file
118
app/views/enable_banking_items/setup_accounts.html.erb
Normal 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 %>
|
||||
@@ -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" %>
|
||||
|
||||
|
||||
188
app/views/settings/providers/_enable_banking_panel.html.erb
Normal file
188
app/views/settings/providers/_enable_banking_panel.html.erb
Normal 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>
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
52
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 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"
|
||||
|
||||
Reference in New Issue
Block a user