Files
sure/app/controllers/enable_banking_items_controller.rb
Claude ddab962d76 Fetch transaction counts during provider setup
Add transaction count validation for all banking providers (SimpleFIN,
Lunch Flow, and Enable Banking) during the account setup process.

This change fetches transaction data for each bank account immediately
after provider credentials are configured, allowing users to see
warnings about accounts with no transaction history before completing
the setup.

Key changes:
- SimpleFIN: Fetch accounts and check transaction counts after token setup
- Lunch Flow: Check transaction availability after API key configuration
- Enable Banking: Validate transaction data after OAuth authorization
- Display warning messages in provider panels when issues are detected
- Warnings show accounts with 0 transactions in the last 90 days

The warnings appear in the /settings/providers screen before the
"Configured and ready to use" message, giving users early visibility
into potential data availability issues.
2025-11-30 21:27:32 +00:00

525 lines
20 KiB
Ruby

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)
# Fetch transaction counts for validation
transaction_warnings = fetch_transaction_counts(enable_banking_item)
# Trigger sync to process accounts
enable_banking_item.sync_later
if transaction_warnings.any?
# Store warnings in flash for display on accounts page
flash[:warning] = "Connected successfully, but some issues were found: #{transaction_warnings.join('; ')}"
end
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
# Fetch transaction counts for all accounts in the Enable Banking item
# Returns an array of warning messages if any accounts have issues
def fetch_transaction_counts(enable_banking_item)
warnings = []
begin
provider = enable_banking_item.enable_banking_provider
return warnings unless provider
accounts = enable_banking_item.enable_banking_accounts
if accounts.empty?
warnings << "No bank accounts found after authorization."
else
# Check transaction counts for each account (last 90 days)
accounts.each do |enable_banking_account|
account_name = enable_banking_account.name || "Unknown Account"
account_uid = enable_banking_account.uid
begin
transactions_data = provider.get_account_transactions(
account_id: account_uid,
date_from: 90.days.ago,
date_to: Date.today
)
transactions = transactions_data[:transactions] || []
if transactions.empty?
warnings << "Account '#{account_name}' has 0 transactions available in the last 90 days."
end
rescue Provider::EnableBanking::EnableBankingError => e
Rails.logger.warn("Enable Banking transaction count check failed for account #{account_uid}: #{e.message}")
warnings << "Unable to fetch transactions for '#{account_name}': #{e.message}"
end
end
end
rescue Provider::EnableBanking::EnableBankingError => e
Rails.logger.warn("Enable Banking accounts fetch failed: #{e.message}")
warnings << "Unable to fetch account information: #{e.message}"
rescue => e
Rails.logger.warn("Unexpected error checking Enable Banking transactions: #{e.message}")
warnings << "Unable to verify transaction availability."
end
warnings
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